Compare commits

..

38 Commits

Author SHA1 Message Date
Maurice fc7d8b5d12 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.
2026-05-30 02:39:26 +02:00
Maurice 6d2dd37414 feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit 67cf290cda.

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

This reverts commit f92b95e054.

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

This reverts commit 797183de08.

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

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

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

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

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

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

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

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
2026-05-25 14:29:30 +02:00
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
1357 changed files with 104050 additions and 54915 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules
client/node_modules
server/node_modules
client/dist
shared/dist
data
uploads
.git
+3 -4
View File
@@ -102,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
+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
+37
View File
@@ -0,0 +1,37 @@
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
exit-code: true
+45 -7
View File
@@ -8,10 +8,33 @@ on:
branches: [main, dev]
paths:
- 'server/**'
- '.github/workflows/test.yml'
- 'client/**'
- 'shared/**'
- '.github/workflows/test.yml'
jobs:
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 +44,24 @@ 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 --workspace shared && npm ci --workspace server
- name: Build shared
run: npm run build --workspace=shared
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Typecheck (informational)
# Pre-existing type errors in the NestJS rewrite; surfaces them without
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
continue-on-error: true
run: cd server && npm run typecheck
- name: Run tests
run: cd server && npm run test:coverage
@@ -48,12 +83,15 @@ 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: Run tests
run: cd client && npm run test:coverage
+2
View File
@@ -3,6 +3,8 @@ node_modules/
# Build output
client/dist/
server/dist/
shared/dist/
server/public/*
!server/public/.gitkeep
+52 -19
View File
@@ -1,28 +1,60 @@
# 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 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: Production server
FROM node:22-alpine
# ── 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-alpine
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 install.
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --workspace=server --omit=dev && \
apk del python3 make g++ && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
COPY --from=server-builder /app/server/dist ./server/dist
# 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 +68,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 su-exec node node --require tsconfig-paths/register dist/index.js"]
+1 -1
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;
+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.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.16
version: 3.0.22
description: Minimal Helm chart for TREK app
appVersion: "3.0.16"
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 })
})
+1
View File
@@ -19,6 +19,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
+28 -10
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.16",
"name": "@trek/client",
"version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
@@ -12,21 +12,29 @@
"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: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",
"@react-pdf/renderer": "^4.5.1",
"@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,23 +42,33 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
"@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-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",
+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.')
+1 -1
View File
@@ -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)
+170 -93
View File
@@ -1,31 +1,89 @@
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import {
weatherResultSchema, type WeatherResult,
inAppListResultSchema, type InAppListResult,
unreadCountResultSchema, type UnreadCountResult,
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 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,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
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'
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>
}
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
}
@@ -151,12 +209,12 @@ apiClient.interceptors.response.use(
)
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),
@@ -170,14 +228,14 @@ 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),
},
}
@@ -209,7 +267,7 @@ export const oauthApi = {
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
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),
@@ -223,32 +281,32 @@ 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),
}
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 }) => {
@@ -267,64 +325,64 @@ export const placesApi = {
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),
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
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),
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),
}
@@ -387,7 +445,7 @@ export const addonsApi = {
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),
@@ -396,7 +454,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
@@ -404,12 +462,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),
@@ -431,7 +501,7 @@ 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),
}
@@ -453,15 +523,15 @@ 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),
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),
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 = {
@@ -469,28 +539,29 @@ 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),
}
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 = {
@@ -500,40 +571,46 @@ export const configApi = {
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),
}
@@ -580,10 +657,10 @@ export const notificationsApi = {
}
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),
markUnread: (id: number) =>
@@ -594,7 +671,7 @@ export const inAppNotificationsApi = {
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') =>
respond: (id: number, response: NotificationRespondRequest['response']) =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
@@ -20,7 +20,6 @@ type Defaults = {
temperature_unit?: string
dark_mode?: string | boolean
time_format?: string
route_calculation?: boolean
blur_booking_codes?: boolean
map_tile_url?: string
}
@@ -208,22 +207,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" /></>}>
{([
+38 -10
View File
@@ -771,12 +771,40 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped.get(cat) || []
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = categoryColor(cat)
{categoryNames.map(cat => (
<BudgetCategoryTable key={cat} cat={cat} grouped={grouped} categoryColor={categoryColor}
canEdit={canEdit} editingCat={editingCat} setEditingCat={setEditingCat}
dragCat={dragCat} setDragCat={setDragCat} dragOverCat={dragOverCat} setDragOverCat={setDragOverCat}
dragItem={dragItem} setDragItem={setDragItem} dragOverItem={dragOverItem} setDragOverItem={setDragOverItem}
dragItemCat={dragItemCat} setDragItemCat={setDragItemCat}
categoryNames={categoryNames} reorderBudgetCategories={reorderBudgetCategories} reorderBudgetItems={reorderBudgetItems}
handleRenameCategory={handleRenameCategory} handleDeleteCategory={handleDeleteCategory} handleDeleteItem={handleDeleteItem}
handleUpdateField={handleUpdateField} handleAddItem={handleAddItem}
tripId={tripId} currency={currency} locale={locale} t={t} fmt={fmt}
hasMultipleMembers={hasMultipleMembers} tripMembers={tripMembers}
setBudgetItemMembers={setBudgetItemMembers} toggleBudgetMemberPaid={toggleBudgetMemberPaid}
th={th} td={td} />
))}
</div>
return (
<BudgetSummary theme={theme} currency={currency} locale={locale} grandTotal={grandTotal}
hasMultipleMembers={hasMultipleMembers} budgetItems={budgetItems} settlement={settlement}
settlementOpen={settlementOpen} setSettlementOpen={setSettlementOpen} pieSegments={pieSegments}
isDark={isDark} tripId={tripId} t={t} fmt={fmt} />
</div>
</div>
)
}
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 }: any) {
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',
@@ -975,10 +1003,12 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</table>
</div>
</div>
)
})}
</div>
)
}
function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: any) {
return (
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{
@@ -1227,7 +1257,5 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})()}
</div>
</div>
</div>
)
}
+99 -87
View File
@@ -353,6 +353,99 @@ interface CollabChatProps {
}
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
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' }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
return (
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<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-3">
{/* Reply preview */}
{replyTo && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
<X size={14} />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
)}
<textarea
ref={textareaRef}
rows={1}
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
/>
{/* Send */}
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
)}
</div>
</div>
{/* Emoji picker */}
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
{/* Reaction quick menu (right-click) */}
{reactMenu && ReactDOM.createPortal(
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
document.body
)}
</div>
)
}
function useCollabChat(tripId: any, currentUser: any) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const can = useCanDo()
@@ -519,19 +612,13 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
return emojiRegex.test(text.trim())
}
/* ── Loading ── */
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
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 }
}
/* ── Main ── */
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 (
<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 }}>
@@ -767,81 +854,6 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)}
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3">
{/* Reply preview */}
{replyTo && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
}}>
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
</span>
<button onClick={() => setReplyTo(null)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
display: 'flex', flexShrink: 0,
}}>
<X size={14} />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
}}>
<Smile size={20} />
</button>
)}
<textarea
ref={textareaRef}
rows={1}
disabled={!canEdit}
style={{
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
maxHeight: 100, overflowY: 'hidden',
opacity: canEdit ? 1 : 0.5,
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
/>
{/* Send */}
{canEdit && (
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
)}
</div>
</div>
{/* Emoji picker */}
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
{/* Reaction quick menu (right-click) */}
{reactMenu && ReactDOM.createPortal(
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
document.body
)}
</div>
</>
)
}
+247 -298
View File
@@ -906,7 +906,12 @@ interface CollabNotesProps {
currentUser: User
}
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
/**
* Collab notes state: load + WebSocket sync, note CRUD (with file uploads),
* category colors/renames and the view/edit/settings modal toggles. The shell
* below renders the header, category pills, the note grid and the modals.
*/
function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
@@ -1082,316 +1087,263 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
return tB - tA
})
// ── Loading state ──
if (loading) {
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
fontFamily: FONT,
}}>
<div style={{
padding: '12px 16px',
borderBottom: '1px solid var(--border-faint)',
}}>
<h3 style={{
fontSize: 14,
fontWeight: 700,
color: 'var(--text-primary)',
margin: 0,
fontFamily: FONT,
}}>
{t('collab.notes.title')}
</h3>
</div>
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid var(--border-primary)',
borderTopColor: 'var(--text-primary)',
borderRadius: '50%',
animation: 'collab-notes-spin 0.7s linear infinite',
}} />
<style>{`@keyframes collab-notes-spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
)
return {
tripId, currentUser, t, canEdit,
notes, loading, showNewModal, setShowNewModal, editingNote, setEditingNote,
viewingNote, setViewingNote, previewFile, setPreviewFile, showSettings, setShowSettings,
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
}
}
type NotesState = ReturnType<typeof useCollabNotes>
function CollabNotesLoading({ t }: NotesState) {
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
fontFamily: FONT,
}}>
{/* ── Header ── */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
flexShrink: 0,
}}>
<h3 style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-muted)',
margin: 0,
fontFamily: FONT,
letterSpacing: 0.3,
textTransform: 'uppercase',
display: 'flex',
alignItems: 'center',
gap: 7,
}}>
<StickyNote size={14} color="var(--text-faint)" />
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0, fontFamily: FONT }}>
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>}
</div>
</div>
{/* ── Category filter pills ── */}
{categories.length > 0 && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
display: 'flex',
gap: 4,
padding: '8px 12px 0',
overflowX: 'auto',
flexShrink: 0,
width: 20, height: 20, border: '2px solid var(--border-primary)',
borderTopColor: 'var(--text-primary)', borderRadius: '50%',
animation: 'collab-notes-spin 0.7s linear infinite',
}} />
<style>{`@keyframes collab-notes-spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
)
}
function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: NotesState) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
<h3 style={{
fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', margin: 0, fontFamily: FONT,
letterSpacing: 0.3, textTransform: 'uppercase', display: 'flex', alignItems: 'center', gap: 7,
}}>
<StickyNote size={14} color="var(--text-faint)" />
{t('collab.notes.title')}
</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Settings size={14} />
</button>}
{canEdit && <button onClick={() => setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<Plus size={12} />
{t('collab.notes.new')}
</button>}
</div>
</div>
)
}
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) {
return (
<div style={{ display: 'flex', gap: 4, padding: '8px 12px 0', overflowX: 'auto', flexShrink: 0 }}>
<button
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === null ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === null ? 'var(--accent)' : 'transparent',
color: activeCategory === null ? 'var(--accent-text)' : 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
}}
>
{t('collab.notes.all')}
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 10, fontWeight: 600, fontFamily: FONT,
border: activeCategory === cat ? '1px solid var(--accent)' : '1px solid var(--border-faint)',
background: activeCategory === cat ? 'var(--accent)' : 'transparent',
color: activeCategory === cat ? 'var(--accent-text)' : 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap', textTransform: 'uppercase', letterSpacing: '0.03em',
}}
>
{cat}
</button>
))}
</div>
)
}
function CollabNotesGrid(S: NotesState) {
const {
sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
} = S
return (
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{sortedNotes.length === 0 ? (
/* ── Empty state ── */
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: '48px 20px', textAlign: 'center', height: '100%',
}}>
<button
onClick={() => setActiveCategory(null)}
style={{
flexShrink: 0,
borderRadius: 99,
padding: '3px 10px',
fontSize: 10,
fontWeight: 600,
fontFamily: FONT,
border: activeCategory === null
? '1px solid var(--accent)'
: '1px solid var(--border-faint)',
background: activeCategory === null
? 'var(--accent)'
: 'transparent',
color: activeCategory === null
? 'var(--accent-text)'
: 'var(--text-secondary)',
cursor: 'pointer',
whiteSpace: 'nowrap',
textTransform: 'uppercase',
letterSpacing: '0.03em',
}}
>
{t('collab.notes.all')}
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
style={{
flexShrink: 0,
borderRadius: 99,
padding: '3px 10px',
fontSize: 10,
fontWeight: 600,
fontFamily: FONT,
border: activeCategory === cat
? '1px solid var(--accent)'
: '1px solid var(--border-faint)',
background: activeCategory === cat
? 'var(--accent)'
: 'transparent',
color: activeCategory === cat
? 'var(--accent-text)'
: 'var(--text-secondary)',
cursor: 'pointer',
whiteSpace: 'nowrap',
textTransform: 'uppercase',
letterSpacing: '0.03em',
}}
>
{cat}
</button>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
{t('collab.notes.empty')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontFamily: FONT }}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div>
</div>
) : (
/* ── Notes grid — 2 columns ── */
<div style={{
display: 'grid',
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, 1fr)',
gap: 8,
}}>
{sortedNotes.map(note => (
<NoteCard
key={note.id}
note={note}
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor}
tripId={tripId}
t={t}
/>
))}
</div>
)}
</div>
)
}
{/* ── Scrollable content ── */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: 12,
}}>
{sortedNotes.length === 0 ? (
/* ── Empty state ── */
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 20px',
textAlign: 'center',
height: '100%',
}}>
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
<div style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-secondary)',
marginBottom: 4,
fontFamily: FONT,
}}>
{t('collab.notes.empty')}
</div>
<div style={{
fontSize: 12,
color: 'var(--text-faint)',
fontFamily: FONT,
}}>
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
</div>
function ViewNoteModal(S: NotesState) {
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
if (!viewingNote) return null
return ReactDOM.createPortal(
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
>
<div
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
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: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
}}>{viewingNote.category}</span>
)}
</div>
) : (
/* ── Notes grid — 2 columns ── */
<div style={{
display: 'grid',
gridTemplateColumns: window.innerWidth < 768 ? '1fr' : 'repeat(2, 1fr)',
gap: 8,
}}>
{sortedNotes.map(note => (
<NoteCard
key={note.id}
note={note}
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
getCategoryColor={getCategoryColor}
tripId={tripId}
t={t}
/>
))}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>}
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
)}
</div>
{/* ── New Note Modal ── */}
{/* View note modal */}
{viewingNote && ReactDOM.createPortal(
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, padding: 16,
}}
onClick={() => setViewingNote(null)}
>
<div
style={{
background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
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: 17, fontWeight: 600, color: 'var(--text-primary)' }}>{viewingNote.title}</div>
{viewingNote.category && (
<span style={{
display: 'inline-block', marginTop: 4, fontSize: 10, fontWeight: 600,
color: getCategoryColor(viewingNote.category),
background: `${getCategoryColor(viewingNote.category)}18`,
padding: '2px 8px', borderRadius: 6,
}}>{viewingNote.category}</span>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={16} />
</button>}
<button onClick={() => setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return (
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
{isImage ? (
<AuthedImg src={a.url} alt={a.original_name}
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => setPreviewFile(a)}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div title={a.original_name} onClick={() => setPreviewFile(a)}
style={{
width: 64, height: 64, 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.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{(viewingNote.attachments || []).map(a => {
const isImage = a.mime_type?.startsWith('image/')
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
return (
<div key={a.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, maxWidth: 72 }}>
{isImage ? (
<AuthedImg src={a.url} alt={a.original_name}
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
onClick={() => setPreviewFile(a)}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
) : (
<div title={a.original_name} onClick={() => setPreviewFile(a)}
style={{
width: 64, height: 64, 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.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
<span style={{ fontSize: 10, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
</div>
)
})}
</div>
</div>
)}
)}
<span style={{ fontSize: 9, color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
</div>
)
})}
</div>
</div>
</div>
</div>,
document.body
)}
)}
</div>
</div>
</div>,
document.body
)
}
export default function CollabNotes(props: CollabNotesProps) {
const S = useCollabNotes(props)
const {
loading, tripId, t, categories, categoryColors, getCategoryColor, notes,
viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
} = S
if (loading) return <CollabNotesLoading {...S} />
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
<CollabNotesHeader {...S} />
{categories.length > 0 && <CollabCategoryPills {...S} />}
<CollabNotesGrid {...S} />
{viewingNote && <ViewNoteModal {...S} />}
{showNewModal && (
<NoteFormModal
@@ -1406,7 +1358,6 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
/>
)}
{/* ── Edit Note Modal ── */}
{editingNote && (
<NoteFormModal
note={editingNote}
@@ -1421,10 +1372,8 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
/>
)}
{/* ── File Preview ── */}
<FilePreviewPortal file={previewFile} onClose={() => setPreviewFile(null)} />
{/* ── Category Settings Modal ── */}
{showSettings && (
<CategorySettingsModal
onClose={() => setShowSettings(false)}
File diff suppressed because it is too large Load Diff
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
return (
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
<button
+13 -70
View File
@@ -1,4 +1,4 @@
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-006
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -16,7 +16,7 @@ vi.mock('react-router-dom', async () => {
return { ...actual, useNavigate: () => mockNavigate };
});
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
@@ -39,82 +39,25 @@ describe('BottomNav', () => {
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
it('FE-COMP-BOTTOMNAV-002: shows the dashboard nav item', () => {
render(<BottomNav />);
expect(screen.getByText('My Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
render(<BottomNav />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
it('FE-COMP-BOTTOMNAV-003: centre create button creates a new trip by default', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Profile sheet shows username
expect(screen.getByText('testuser')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'New Trip' }));
expect(mockNavigate).toHaveBeenCalledWith('/dashboard?create=1');
});
it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('testuser')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => {
const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' });
seedStore(useAuthStore, { user: adminUser, isAuthenticated: true });
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Sheet is open — username visible
expect(screen.getByText('testuser')).toBeInTheDocument();
// The outermost fixed div is the backdrop wrapper, clicking it triggers onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop);
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
it('FE-COMP-BOTTOMNAV-004: dashboard label translates when language is fr', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
expect(await screen.findByText('Mes voyages')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Profil')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
it('FE-COMP-BOTTOMNAV-005: addon labels translate when language is fr', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
seedStore(useAddonStore, {
addons: [
@@ -124,12 +67,12 @@ describe('BottomNav', () => {
],
});
render(<BottomNav />);
expect(screen.getByText('Vacances')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
expect(await screen.findByText('Vacances')).toBeInTheDocument();
expect(await screen.findByText('Atlas')).toBeInTheDocument();
expect(await screen.findByText('Journal de voyage')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
it('FE-COMP-BOTTOMNAV-006: unknown addon id is not rendered', () => {
seedStore(useAddonStore, {
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
});
+85 -131
View File
@@ -1,10 +1,8 @@
import { useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { useNavigate, useLocation, useMatch } from 'react-router-dom'
import { useAddonStore } from '../../store/addonStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
import { LayoutGrid, CalendarDays, Globe, Compass, Plus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
@@ -13,150 +11,106 @@ const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
}
interface NavItem { to: string; label: string; icon: LucideIcon }
// The centre "+" means something different per context: inside a trip it adds a
// place, on the journey list it starts a journey, inside a journey it adds an
// entry — everywhere else it creates a new trip. Pages pick the intent up from
// the ?create= query param.
function useCreateAction(): { label: string; run: () => void } {
const navigate = useNavigate()
const { t } = useTranslation()
const inTrip = useMatch('/trips/:id')
const inJourney = useMatch('/journey/:id')
const onJourneyList = useMatch('/journey')
if (inTrip) {
return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
}
if (inJourney) {
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
}
if (onJourneyList) {
return { label: t('journey.new'), run: () => navigate('/journey?create=1') }
}
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') }
}
export default function BottomNav() {
const { t } = useTranslation()
const navigate = useNavigate()
const darkMode = useSettingsStore(s => s.settings.dark_mode)
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const addons = useAddonStore(s => s.addons)
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
const [showProfile, setShowProfile] = useState(false)
const location = useLocation()
const create = useCreateAction()
const items: { to: string; label: string; icon: LucideIcon }[] = [
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
const items: NavItem[] = [
{ to: '/dashboard', label: t('nav.myTrips'), icon: LayoutGrid },
...globalAddons.flatMap(addon => {
const nav = ADDON_NAV[addon.id]
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
}),
]
// Split the items so the raised "+" sits dead centre.
const splitAt = Math.ceil(items.length / 2)
const left = items.slice(0, splitAt)
const right = items.slice(splitAt)
const isActive = (to: string) =>
to === '/dashboard' ? location.pathname === '/dashboard' : location.pathname.startsWith(to)
const renderItem = ({ to, label, icon: Icon }: NavItem) => {
const active = isActive(to)
return (
<button
key={to}
onClick={() => navigate(to)}
className="flex flex-col items-center gap-1 py-1 px-1 min-w-0"
style={{ color: active ? (dark ? '#fff' : 'oklch(0.22 0 0)') : (dark ? 'oklch(0.6 0 0)' : 'oklch(0.62 0.01 65)') }}
>
<Icon size={21} strokeWidth={active ? 2.4 : 1.9} />
<span className="text-[10px] font-semibold tracking-tight truncate max-w-full">{label}</span>
</button>
)
}
return (
<>
<nav
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
<nav
className="md:hidden fixed z-[60] flex items-center"
style={{
left: 12, right: 12,
bottom: 'calc(12px + env(safe-area-inset-bottom, 0px))',
padding: '8px 8px',
borderRadius: 24,
background: dark ? 'oklch(0.2 0 0 / 0.72)' : 'rgba(255,255,255,0.78)',
backdropFilter: 'saturate(1.7) blur(22px)',
WebkitBackdropFilter: 'saturate(1.7) blur(22px)',
border: dark ? '1px solid oklch(1 0 0 / .1)' : '1px solid oklch(0.92 0.008 70 / .6)',
boxShadow: dark
? '0 12px 40px -8px oklch(0 0 0 / .6), inset 0 1px 0 oklch(1 0 0 / .08)'
: '0 12px 40px -8px oklch(0 0 0 / .22), inset 0 1px 0 oklch(1 0 0 / .8)',
}}
>
<div className="flex flex-1 items-center justify-around min-w-0">{left.map(renderItem)}</div>
<button
onClick={create.run}
aria-label={create.label}
className="flex items-center justify-center flex-shrink-0 active:scale-95 transition-transform"
style={{
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
width: 46, height: 46, marginInline: 8,
borderRadius: '50%',
background: dark ? '#fff' : 'oklch(0.22 0 0)',
color: dark ? 'oklch(0.22 0 0)' : '#fff',
boxShadow: '0 4px 12px oklch(0 0 0 / .22)',
}}
>
{items.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
}`
}
>
<Icon size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{label}</span>
</NavLink>
))}
<button
onClick={() => setShowProfile(true)}
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
>
<User size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
</button>
</nav>
<Plus size={24} strokeWidth={2.6} />
</button>
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
</>
)
}
function ProfileSheet({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const handleNav = (path: string) => {
onClose()
navigate(path)
}
const handleLogout = () => {
onClose()
logout()
navigate('/login')
}
return (
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Sheet */}
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* User info */}
<div className="px-6 pb-4 pt-1">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
{(user?.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
</div>
{user?.role === 'admin' && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
<Shield size={10} /> Admin
</span>
)}
</div>
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Links */}
<div className="py-2 px-2">
<button
onClick={() => handleNav('/settings')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
</button>
{user?.role === 'admin' && (
<button
onClick={() => handleNav('/admin')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
</button>
)}
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Logout */}
<div className="py-2 px-2">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
</button>
</div>
<div className="h-4" />
</div>
</div>
<div className="flex flex-1 items-center justify-around min-w-0">{right.map(renderItem)}</div>
</nav>
)
}
@@ -0,0 +1,80 @@
// FE-COMP-MOBILETOPBAR-001 to FE-COMP-MOBILETOPBAR-007
vi.mock('./InAppNotificationBell', () => ({ default: () => null }));
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import MobileTopBar from './MobileTopBar';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
});
describe('MobileTopBar', () => {
it('FE-COMP-MOBILETOPBAR-001: renders the profile avatar (no brand logo)', () => {
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument();
expect(screen.queryByText('trek')).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPBAR-002: avatar opens the profile sheet', async () => {
const user = userEvent.setup();
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
await user.click(screen.getByRole('button', { name: 'Profile' }));
expect(screen.getByText('testuser')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPBAR-003: profile sheet shows Settings', async () => {
const user = userEvent.setup();
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
await user.click(screen.getByRole('button', { name: 'Profile' }));
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPBAR-004: profile sheet shows Logout', async () => {
const user = userEvent.setup();
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
await user.click(screen.getByRole('button', { name: 'Profile' }));
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPBAR-005: admin badge shown for admin users', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 2, username: 'adminuser', role: 'admin' }), isAuthenticated: true });
const user = userEvent.setup();
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
await user.click(screen.getByRole('button', { name: 'Profile' }));
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPBAR-006: backdrop click closes the profile sheet', async () => {
const user = userEvent.setup();
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
await user.click(screen.getByRole('button', { name: 'Profile' }));
expect(screen.getByText('testuser')).toBeInTheDocument();
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop);
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPBAR-007: profile label translates when language is fr', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
expect(await screen.findByRole('button', { name: 'Profil' })).toBeInTheDocument();
});
});
@@ -0,0 +1,128 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore'
import { useTranslation } from '../../i18n'
import { Bell, Settings, Shield, LogOut } from 'lucide-react'
// Mobile-only: a slim strip at the very top of the dashboard with the
// notification + profile icons (right-aligned). Scrolls with the page.
export default function MobileTopBar() {
const { t } = useTranslation()
const navigate = useNavigate()
const { user, isAuthenticated } = useAuthStore()
const unread = useInAppNotificationStore(s => s.unreadCount)
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
const [showProfile, setShowProfile] = useState(false)
useEffect(() => { if (isAuthenticated) fetchUnreadCount() }, [isAuthenticated])
return (
<>
<div
className="md:hidden flex items-center justify-end gap-2 px-4"
style={{ paddingTop: 'calc(10px + env(safe-area-inset-top, 0px))', paddingBottom: 10 }}
>
<button
onClick={() => navigate('/notifications')}
aria-label={t('notifications.title')}
className="relative grid place-items-center rounded-full active:scale-95 transition-transform"
style={{ width: 36, height: 36, color: 'var(--ink-2, #52525b)' }}
>
<Bell size={20} strokeWidth={1.9} />
{unread > 0 && (
<span style={{ position: 'absolute', top: 7, right: 7, width: 8, height: 8, borderRadius: '50%', background: 'oklch(0.7 0.17 38)', boxShadow: '0 0 0 2px var(--bg, #fff)' }} />
)}
</button>
<button
onClick={() => setShowProfile(true)}
aria-label={t('nav.profile')}
className="grid place-items-center rounded-full text-white font-semibold text-[12px] active:scale-95 transition-transform"
style={{ width: 34, height: 34, background: 'linear-gradient(135deg, oklch(0.7 0.14 38), oklch(0.55 0.13 25))' }}
>
{(user?.username || '?')[0].toUpperCase()}
</button>
</div>
{showProfile && createPortal(<ProfileSheet onClose={() => setShowProfile(false)} />, document.body)}
</>
)
}
function ProfileSheet({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const handleNav = (path: string) => { onClose(); navigate(path) }
const handleLogout = () => { onClose(); logout(); navigate('/login') }
return (
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
>
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
<div className="px-6 pb-4 pt-1">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
{(user?.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
</div>
{user?.role === 'admin' && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
<Shield size={10} /> Admin
</span>
)}
</div>
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
<div className="py-2 px-2">
<button
onClick={() => handleNav('/settings')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t('nav.bottomSettings')}</span>
</button>
{user?.role === 'admin' && (
<button
onClick={() => handleNav('/admin')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t('nav.bottomAdmin')}</span>
</button>
)}
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
<div className="py-2 px-2">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t('nav.bottomLogout')}</span>
</button>
</div>
<div className="h-4" />
</div>
</div>
)
}
+37 -36
View File
@@ -24,6 +24,7 @@ interface Addon {
name: string
icon: string
type: string
enabled: boolean
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
@@ -123,42 +124,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
</Link>
{/* Global addon nav items */}
{globalAddons.length > 0 && !tripTitle && (
<>
<span style={{ color: 'var(--text-faint)' }}>|</span>
<Link to="/dashboard"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
style={{
color: location.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-muted)',
background: location.pathname === '/dashboard' ? 'var(--bg-hover)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => { if (location.pathname !== '/dashboard') e.currentTarget.style.background = 'transparent' }}>
<Briefcase className="w-3.5 h-3.5" />
<span className="hidden md:inline">{t('nav.myTrips')}</span>
</Link>
{globalAddons.map(addon => {
const Icon = ADDON_ICONS[addon.icon] || CalendarDays
const path = `/${addon.id}`
const isActive = location.pathname === path
return (
<Link key={addon.id} to={path}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
style={{
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
background: isActive ? 'var(--bg-hover)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
<Icon className="w-3.5 h-3.5" />
<span className="hidden md:inline">{getAddonName(addon)}</span>
</Link>
)
})}
</>
)}
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
@@ -169,6 +134,42 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
</div>
{/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so
the left brand block and the right action cluster keep their layout. */}
{globalAddons.length > 0 && !tripTitle && (
<div
className="trek-nav-pill"
style={{
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
display: 'flex', gap: 4, padding: 4, borderRadius: 14,
background: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
backdropFilter: 'blur(20px) saturate(180%)', WebkitBackdropFilter: 'blur(20px) saturate(180%)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'}`,
}}
>
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays }))
].map(tab => {
const isActive = location.pathname === tab.path
return (
<Link key={tab.id} to={tab.path}
className="flex items-center gap-1.5 transition-colors"
style={{
padding: '5px 16px', borderRadius: 9, fontSize: 13.5, fontWeight: 500,
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
background: isActive ? 'var(--bg-card)' : 'transparent',
boxShadow: isActive ? '0 1px 2px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.05)' : 'none',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-muted)' }}>
<tab.Icon className="w-4 h-4" />
<span>{tab.label}</span>
</Link>
)
})}
</div>
)}
{/* Spacer */}
<div className="flex-1" />
@@ -0,0 +1,42 @@
import React from 'react'
import Navbar from './Navbar'
interface PageShellProps {
children: React.ReactNode
/** Tailwind classes for the full-height root (e.g. "bg-zinc-50 dark:bg-zinc-950"). */
className?: string
/** Inline `background` for the root, for pages that theme via CSS vars (e.g. "var(--bg-secondary)"). */
background?: string
/** Props forwarded to the shared Navbar (trip title, back button, …). */
navbar?: React.ComponentProps<typeof Navbar>
/** paddingTop offset that clears the fixed Navbar. Defaults to the global --nav-h. */
navOffset?: string
/** Classes/style for the nav-offset content wrapper. */
contentClassName?: string
contentStyle?: React.CSSProperties
}
/**
* The standard authenticated page chrome: a full-height themed root, the shared
* fixed Navbar, and a content wrapper offset by the navbar height. Both the web
* app and the PWA shell render through this so the offset/background handling
* lives in one place instead of being copy-pasted into every page.
*/
export default function PageShell({
children,
className,
background,
navbar,
navOffset = 'var(--nav-h)',
contentClassName,
contentStyle,
}: PageShellProps): React.ReactElement {
return (
<div className={`min-h-screen${className ? ' ' + className : ''}`} style={background ? { background } : undefined}>
<Navbar {...navbar} />
<div className={contentClassName} style={{ paddingTop: navOffset, ...contentStyle }}>
{children}
</div>
</div>
)
}
+7 -11
View File
@@ -128,7 +128,8 @@ describe('MapView', () => {
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
expect(screen.getByTestId('polyline')).toBeTruthy()
// Apple-Maps style draws a casing + a core line per segment.
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
@@ -155,16 +156,11 @@ describe('MapView', () => {
expect(screen.getByTestId('cluster-group')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
const routeSegments = [
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
]
render(<MapView route={route} routeSegments={routeSegments} />)
// Route polyline is rendered
expect(screen.getByTestId('polyline')).toBeTruthy()
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
// so we just assert the polyline is there, exercising the routeSegments.map path
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
render(<MapView route={route} />)
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
+15 -56
View File
@@ -43,7 +43,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
const cached = iconCache.get(cacheKey)
if (cached) return cached
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
const borderColor = isSelected ? '#111827' : (place.category_color || 'white')
const borderWidth = isSelected ? 3 : 2.5
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
@@ -225,44 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
return null
}
// ── Route travel time label ──
interface RouteLabelProps {
midpoint: [number, number]
walkingText: string
drivingText: string
}
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
if (!midpoint) return null
const icon = L.divIcon({
className: 'route-info-pill',
html: `<div style="
display:flex;align-items:center;gap:5px;
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
color:#fff;border-radius:99px;padding:3px 9px;
font-size:9px;font-weight:600;white-space:nowrap;
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
box-shadow:0 2px 12px rgba(0,0,0,0.3);
pointer-events:none;
position:relative;left:-50%;top:-50%;
">
<span style="display:flex;align-items:center;gap:2px">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
${walkingText}
</span>
<span style="opacity:0.3">|</span>
<span style="display:flex;align-items:center;gap:2px">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
${drivingText}
</span>
</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
})
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
}
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -586,23 +549,19 @@ export const MapView = memo(function MapView({
{markers}
</MarkerClusterGroup>
{route && route.length > 0 && (
<>
{route.map((seg, i) => seg.length > 1 && (
<Polyline
key={i}
positions={seg}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
/>
))}
{routeSegments.map((seg, i) => (
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
))}
</>
)}
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
/>,
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
/>,
] : [])}
{/* GPX imported route geometries */}
{gpxPolylines}
+14 -7
View File
@@ -53,7 +53,7 @@ interface Props {
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
const size = selected ? 44 : 36
const borderColor = selected ? '#111827' : 'white'
const borderColor = selected ? '#111827' : (place.category_color || 'white')
const borderWidth = selected ? 3 : 2.5
const shadow = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
@@ -132,6 +132,7 @@ export function MapViewGL({
places = [],
dayPlaces = [],
route = null,
routeSegments = [],
selectedPlaceId = null,
onMarkerClick,
onMapClick,
@@ -216,16 +217,20 @@ export function MapViewGL({
// initial route source — kept around so updates can setData() cheaply
if (!map.getSource('trip-route')) {
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// Apple-Maps style: a darker-blue casing under a bright-blue core, both
// rounded. Casing is added first so it sits beneath the core line.
map.addLayer({
id: 'trip-route-casing',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'trip-route-line',
type: 'line',
source: 'trip-route',
paint: {
'line-color': '#111827',
'line-width': 3,
'line-opacity': 0.9,
'line-dasharray': [2, 1.5],
},
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
@@ -442,6 +447,8 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [route])
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
// Update GPX geometries
useEffect(() => {
const map = mapRef.current
+75 -1
View File
@@ -1,7 +1,21 @@
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
// the matching profile so walking routes follow footpaths, not the road network.
const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
}
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
const routeCache = new Map<string, RouteWithLegs>()
const ROUTE_CACHE_MAX = 200
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
export async function calculateRoute(
waypoints: Waypoint[],
@@ -116,12 +130,72 @@ export async function calculateSegments(
const walkingDuration = leg.distance / (5000 / 3600)
return {
mid, from, to,
distance: leg.distance,
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance),
}
})
}
/**
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
* by the exact waypoint list. Throws on OSRM failure so callers can fall back to a
* straight line.
*/
export async function calculateRouteWithLegs(
waypoints: Waypoint[],
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
): Promise<RouteWithLegs> {
if (!waypoints || waypoints.length < 2) {
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const cacheKey = `${profile}:${coords}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
const route = data.routes[0]
const coordinates: [number, number][] = route.geometry.coordinates.map(
([lng, lat]: [number, number]) => [lat, lng]
)
const legs: RouteSegment[] = (route.legs || []).map(
(leg: { distance: number; duration: number }, i: number): RouteSegment => {
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
const walkingDuration = leg.distance / (5000 / 3600)
return {
mid, from, to,
distance: leg.distance,
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance),
durationText: formatDuration(leg.duration),
}
}
)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
return result
}
function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`
+6 -4
View File
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
}
// Terrain is only genuinely useful for the satellite imagery styles — on
// clean flat styles like streets/light/dark it nudges route lines onto
// the DEM while our HTML markers stay at Z=0, which causes the visible
// offset when the map is pitched. Restrict terrain to satellite.
// Terrain is only genuinely useful for styles that benefit from elevation
// data. On flat vector styles (streets/light/dark) it nudges route lines
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
// when the map is pitched. Satellite and Outdoors are the intended styles
// for terrain; markers are re-pinned by syncMarkerAltitudes().
export function wantsTerrain(style: string): boolean {
return style === 'mapbox://styles/mapbox/satellite-v9'
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|| style === 'mapbox://styles/mapbox/outdoors-v12'
}
// 3D can be added to every style now — the standard family has it built-in
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const phase = pdfGetSpanPhase(r, day.id)
const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id)
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
const time = splitReservationDateTime(displayTime).time ?? ''
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return `
<div class="note-card" style="border-left: 3px solid ${color};">
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel';
import PackingListPanel, { itemWeight } from './PackingListPanel';
describe('itemWeight (bag total weight calc)', () => {
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
});
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
expect(itemWeight({ weight_grams: 250 })).toBe(250);
});
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
expect(itemWeight({ quantity: 5 })).toBe(0);
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
expect(itemWeight({})).toBe(0);
});
});
beforeEach(() => {
resetAllStores();
File diff suppressed because it is too large Load Diff
+136 -173
View File
@@ -13,6 +13,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
import { useDayDetail } from './useDayDetail'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -57,9 +59,10 @@ interface DayDetailPanelProps {
rightWidth?: number
collapsed?: boolean
onToggleCollapse?: () => void
mobile?: boolean
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
@@ -75,92 +78,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null)
const [dayAccommodations, setDayAccommodations] = useState<any[]>([])
const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
useEffect(() => {
if (!day?.date || !lat || !lng) { setWeather(null); return }
setLoading(true)
weatherApi.getDetailed(lat, lng, day.date, language)
.then(data => setWeather(data.error ? null : data))
.catch(() => setWeather(null))
.finally(() => setLoading(false))
}, [day?.date, lat, lng, language])
useEffect(() => {
if (!tripId) return
accommodationsApi.list(tripId)
.then(data => {
setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
)
setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
})
.catch(() => {})
}, [tripId, day?.id])
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
const handleSelectPlace = (placeId) => {
setHotelForm(f => ({ ...f, place_id: placeId }))
}
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
const newAcc = data.accommodation
const updated = [...accommodations, newAcc]
setAccommodations(updated)
setAccommodation(newAcc)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
onAccommodationChange?.()
} catch {}
}
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
}
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setAccommodation(null)
onAccommodationChange?.()
} catch {}
}
const {
weather, loading, accommodation, setAccommodation, dayAccommodations, setDayAccommodations,
accommodations, setAccommodations, showHotelPicker, setShowHotelPicker,
hotelDayRange, setHotelDayRange, hotelCategoryFilter, setHotelCategoryFilter,
hotelForm, setHotelForm, handleSelectPlace, handleSaveAccommodation,
updateAccommodationField, handleRemoveAccommodation,
} = useDayDetail(day, days, tripId, lat, lng, language, onAccommodationChange)
if (!day) return null
@@ -173,7 +97,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
@@ -288,7 +212,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{/* ── Reservations for this day's assignments ── */}
{(() => {
const dayAssignments = assignments[String(day.id)] || []
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
const dayReservations = reservations.filter(r => {
if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
return r.day_id === day.id
})
if (dayReservations.length === 0) return null
return (
<div style={{ marginBottom: 0 }}>
@@ -305,12 +233,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
</div>
{r.reservation_time?.includes('T') && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
{r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`}
</span>
)}
{(() => {
const { time: startTime } = splitReservationDateTime(r.reservation_time)
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
if (!startTime && !endTime) return null
return (
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{startTime ? formatTime12(startTime, is12h) : ''}
{endTime ? ` ${formatTime12(endTime, is12h)}` : ''}
</span>
)
})()}
</div>
)
})}
@@ -326,6 +259,104 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
<AccommodationList dayAccommodations={dayAccommodations} day={day} reservations={reservations}
canEditDays={canEditDays} fmtTime={fmtTime} blurCodes={blurCodes} t={t}
setAccommodation={setAccommodation} setHotelForm={setHotelForm} setHotelDayRange={setHotelDayRange}
setShowHotelPicker={setShowHotelPicker} handleRemoveAccommodation={handleRemoveAccommodation} />
<HotelPickerModal showHotelPicker={showHotelPicker} setShowHotelPicker={setShowHotelPicker}
font={font} t={t} hotelDayRange={hotelDayRange} setHotelDayRange={setHotelDayRange} days={days} locale={locale}
hotelForm={hotelForm} setHotelForm={setHotelForm} categories={categories} hotelCategoryFilter={hotelCategoryFilter}
setHotelCategoryFilter={setHotelCategoryFilter} places={places} handleSelectPlace={handleSelectPlace}
accommodation={accommodation} tripId={tripId} day={day} setAccommodations={setAccommodations}
setDayAccommodations={setDayAccommodations} setAccommodation={setAccommodation}
handleSaveAccommodation={handleSaveAccommodation} onAccommodationChange={onAccommodationChange} />
</div>
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
interface ChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
value: string
}
function Chip({ icon: Icon, value }: ChipProps) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{ fontWeight: 500 }}>{value}</span>
</div>
)
}
interface InfoChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
value: string
placeholder: string
onEdit: (value: string) => void
type: 'text' | 'time'
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)
React.useEffect(() => { setVal(value || '') }, [value])
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
const save = () => {
setEditing(false)
if (val !== (value || '')) onEdit(val)
}
return (
<div
onClick={() => setEditing(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
}}
>
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
{editing ? (
<input
ref={inputRef}
type={type}
value={val}
onChange={e => setVal(e.target.value)}
onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
onClick={e => e.stopPropagation()}
style={{
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
}}
/>
) : (
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value || placeholder}
</div>
)}
</div>
</div>
)
}
function AccommodationList({ dayAccommodations, day, reservations, canEditDays, fmtTime, blurCodes, t,
setAccommodation, setHotelForm, setHotelDayRange, setShowHotelPicker, handleRemoveAccommodation }: any) {
return (
<>
{dayAccommodations.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{dayAccommodations.map(acc => {
@@ -438,7 +469,16 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<Hotel size={12} /> {t('day.addAccommodation')}
</button> : null
)}
</>
)
}
function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelDayRange, setHotelDayRange,
days, locale, hotelForm, setHotelForm, categories, hotelCategoryFilter, setHotelCategoryFilter, places,
handleSelectPlace, accommodation, tripId, day, setAccommodations, setDayAccommodations, setAccommodation,
handleSaveAccommodation, onAccommodationChange }: any) {
return (
<>
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
{showHotelPicker && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
@@ -621,83 +661,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
</div>,
document.body
)}
</div>
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}
interface ChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
value: string
}
function Chip({ icon: Icon, value }: ChipProps) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{ fontWeight: 500 }}>{value}</span>
</div>
)
}
interface InfoChipProps {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
label: string
value: string
placeholder: string
onEdit: (value: string) => void
type: 'text' | 'time'
}
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
const [editing, setEditing] = React.useState(false)
const [val, setVal] = React.useState(value || '')
const inputRef = React.useRef(null)
React.useEffect(() => { setVal(value || '') }, [value])
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
const save = () => {
setEditing(false)
if (val !== (value || '')) onEdit(val)
}
return (
<div
onClick={() => setEditing(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
}}
>
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
{editing ? (
<input
ref={inputRef}
type={type}
value={val}
onChange={e => setVal(e.target.value)}
onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
onClick={e => e.stopPropagation()}
style={{
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
}}
/>
) : (
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value || placeholder}
</div>
)}
</div>
</div>
</>
)
}
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Find the pencil/edit button next to the title
const editButtons = screen.getAllByRole('button')
const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title'))
// Click the edit (pencil) button — it's the small one near the title
// The pencil button is inside the title area with opacity 0.35
const titleEl = screen.getByText('Original Title')
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
await user.click(screen.getByLabelText('Edit'))
await waitFor(() => {
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
})
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
const onUpdateDayTitle = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
// Enter edit mode
const titleEl = screen.getByText('Original Title')
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Original Title')
await user.clear(input)
await user.type(input, 'New Title')
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const titleEl = screen.getByText('Original Title')
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Original Title')
await user.keyboard('{Escape}')
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
const onUpdateDayTitle = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
const titleEl = screen.getByText('Old Title')
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
await user.click(screen.getByLabelText('Edit'))
const input = await screen.findByDisplayValue('Old Title')
await user.clear(input)
await user.type(input, 'New Title')
File diff suppressed because it is too large Load Diff
@@ -71,10 +71,15 @@ interface PlaceFormModalProps {
dayAssignments?: Assignment[]
}
export default function PlaceFormModal({
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
* render over the form fields. */
function usePlaceFormModal(props: PlaceFormModalProps) {
const {
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
onCategoryCreated, assignmentId, dayAssignments = [],
}: PlaceFormModalProps) {
} = props
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
@@ -354,6 +359,122 @@ export default function PlaceFormModal({
}
}
return {
isOpen,
onClose,
onSave,
place,
prefillCoords,
tripId,
categories,
onCategoryCreated,
assignmentId,
dayAssignments,
form,
setForm,
mapsSearch,
setMapsSearch,
mapsResults,
setMapsResults,
isSearchingMaps,
setIsSearchingMaps,
newCategoryName,
setNewCategoryName,
showNewCategory,
setShowNewCategory,
isSaving,
setIsSaving,
pendingFiles,
setPendingFiles,
fileRef,
acSuggestions,
setAcSuggestions,
acHighlight,
setAcHighlight,
acDebounceRef,
acAbortRef,
toast,
t,
language,
hasMapsKey,
can,
tripObj,
canUploadFiles,
places,
locationBias,
fetchSuggestions,
handleChange,
handleMapsSearch,
handleSelectMapsResult,
handleSelectSuggestion,
handleSearchKeyDown,
handleCreateCategory,
handleFileAdd,
handleRemoveFile,
handlePaste,
hasTimeError,
handleSubmit,
}
}
export default function PlaceFormModal(props: PlaceFormModalProps) {
const S = usePlaceFormModal(props)
const {
isOpen,
onClose,
onSave,
place,
prefillCoords,
tripId,
categories,
onCategoryCreated,
assignmentId,
dayAssignments,
form,
setForm,
mapsSearch,
setMapsSearch,
mapsResults,
setMapsResults,
isSearchingMaps,
setIsSearchingMaps,
newCategoryName,
setNewCategoryName,
showNewCategory,
setShowNewCategory,
isSaving,
setIsSaving,
pendingFiles,
setPendingFiles,
fileRef,
acSuggestions,
setAcSuggestions,
acHighlight,
setAcHighlight,
acDebounceRef,
acAbortRef,
toast,
t,
language,
hasMapsKey,
can,
tripObj,
canUploadFiles,
places,
locationBias,
fetchSuggestions,
handleChange,
handleMapsSearch,
handleSelectMapsResult,
handleSelectSuggestion,
handleSearchKeyDown,
handleCreateCategory,
handleFileAdd,
handleRemoveFile,
handlePaste,
hasTimeError,
handleSubmit,
} = S
return (
<Modal
isOpen={isOpen}
+373 -329
View File
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime } from '../../utils/formatters'
const detailsCache = new Map()
@@ -169,7 +170,10 @@ export default function PlaceInspector({
const category = categories?.find(c => c.id === place.category_id)
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
const assignmentInDay = selectedDayId
? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
?? dayAssignments.find(a => a.place?.id === place.id))
: null
const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null
@@ -222,90 +226,10 @@ export default function PlaceInspector({
flexDirection: 'column',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
{/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{
borderRadius: '50%', padding: 2.5,
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
}}>
<PlaceAvatar place={place} category={category} size={52} />
</div>
{openNow !== null && (
<span style={{
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
color: 'white',
background: openNow ? '#16a34a' : '#dc2626',
padding: '1.5px 7px', borderRadius: 99,
whiteSpace: 'nowrap',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}>
{openNow ? t('inspector.opened') : t('inspector.closed')}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={commitNameEdit}
onKeyDown={handleNameKeyDown}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }}
/>
) : (
<span
onDoubleClick={startNameEdit}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 500,
color: category.color || '#6b7280',
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
padding: '2px 8px', borderRadius: 99,
}}>
<CatIcon size={10} />
<span className="hidden sm:inline">{category.name}</span>
</span>
)
})()}
</div>
{place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{place.address}</span>
</div>
)}
{place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
</div>
)}
{place.lat && place.lng && (
<div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
</div>
<button
onClick={onClose}
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
</button>
</div>
<PlaceInspectorHeader openNow={openNow} place={place} category={category} t={t} editingName={editingName}
nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue} commitNameEdit={commitNameEdit}
handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit} onUpdatePlace={onUpdatePlace}
locale={locale} timeFormat={timeFormat} onClose={onClose} />
{/* Content — scrollable */}
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -344,7 +268,7 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || googleDetails?.summary) && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div>
)}
@@ -356,251 +280,15 @@ export default function PlaceInspector({
)}
{/* Reservation + Participants — side by side */}
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)
const allJoined = currentParticipants.length === 0
const showParticipants = selectedAssignmentId && tripMembers.length > 1
if (!res && !showParticipants) return null
return (
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{res.reservation_time && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{res.reservation_time?.includes('T') && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</div>
</div>
)}
{res.confirmation_number && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport}${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})()}
{/* Participants */}
{showParticipants && (
<ParticipantsBox
tripMembers={tripMembers}
participantIds={participantIds}
allJoined={allJoined}
onSetParticipants={onSetParticipants}
selectedAssignmentId={selectedAssignmentId}
selectedDayId={selectedDayId}
t={t}
/>
)}
</div>
)
})()}
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
assignments={assignments} selectedDayId={selectedDayId} tripMembers={tripMembers} locale={locale}
timeFormat={timeFormat} t={t} onSetParticipants={onSetParticipants} />
{/* Opening hours + Files — side by side on desktop only if both exist */}
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button
onClick={() => setHoursExpanded(h => !h)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Clock size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
</span>
</div>
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
</button>
{hoursExpanded && (
<div style={{ padding: '0 12px 10px' }}>
{openingHours.map((line, i) => (
<div key={i} style={{
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: i === weekdayIndex ? 600 : 400,
padding: '2px 0',
}}>{convertHoursLine(line, timeFormat)}</div>
))}
</div>
)}
</div>
)}
{/* GPX Track stats */}
{place.route_geometry && (() => {
try {
const pts: number[][] = JSON.parse(place.route_geometry)
if (!pts || pts.length < 2) return null
const hasEle = pts[0].length >= 3
// Haversine distance
const toRad = (d: number) => d * Math.PI / 180
let totalDist = 0
for (let i = 1; i < pts.length; i++) {
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const distKm = totalDist / 1000
// Elevation stats
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
if (hasEle) {
for (let i = 0; i < pts.length; i++) {
const e = pts[i][2]
if (e < minEle) minEle = e
if (e > maxEle) maxEle = e
if (i > 0) {
const diff = e - pts[i - 1][2]
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
}
}
}
// Elevation profile SVG
const chartW = 280, chartH = 60
const elevations = hasEle ? pts.map(p => p[2]) : []
let pathD = ''
if (elevations.length > 1) {
const step = Math.max(1, Math.floor(elevations.length / chartW))
const sampled = elevations.filter((_, i) => i % step === 0)
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
const range = eMax - eMin || 1
pathD = sampled.map((e, i) => {
const x = (i / (sampled.length - 1)) * chartW
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
return (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{Math.round(totalUp)} m &nbsp;{Math.round(totalDown)} m
</div>
</>
)}
</div>
{pathD && (
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<defs>
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)}
</div>
)
} catch { return null }
})()}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button
onClick={() => setFilesExpanded(f => !f)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
>
<FileText size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
</button>
{onFileUpload && (
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
{isUploading ? (
<span style={{ fontSize: 11 }}></span>
) : (
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
)}
</label>
)}
</div>
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<button key={f.id} onClick={() => openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
</button>
))}
</div>
)}
</div>
)}
</div>
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} />
</div>
@@ -819,3 +507,359 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
</div>
)
}
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
{/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{
borderRadius: '50%', padding: 2.5,
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
}}>
<PlaceAvatar place={place} category={category} size={52} />
</div>
{openNow !== null && (
<span style={{
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
color: 'white',
background: openNow ? '#16a34a' : '#dc2626',
padding: '1.5px 7px', borderRadius: 99,
whiteSpace: 'nowrap',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}>
{openNow ? t('inspector.opened') : t('inspector.closed')}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={commitNameEdit}
onKeyDown={handleNameKeyDown}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }}
/>
) : (
<span
onDoubleClick={startNameEdit}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 500,
color: category.color || '#6b7280',
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
padding: '2px 8px', borderRadius: 99,
}}>
<CatIcon size={10} />
<span className="hidden sm:inline">{category.name}</span>
</span>
)
})()}
</div>
{place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{place.address}</span>
</div>
)}
{place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
</div>
)}
{place.lat && place.lng && (
<div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
</div>
<button
onClick={onClose}
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
</button>
</div>
)
}
function PlaceReservationParticipants({ selectedAssignmentId, reservations, assignments, selectedDayId,
tripMembers, locale, timeFormat, t, onSetParticipants }: any) {
return (
<>
{(() => {
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
const currentParticipants = assignment?.participants || []
const participantIds = currentParticipants.map(p => p.user_id)
const allJoined = currentParticipants.length === 0
const showParticipants = selectedAssignmentId && tripMembers.length > 1
if (!res && !showParticipants) return null
return (
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{(() => {
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
return (
<>
{date && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
</div>
)}
{(startTime || endTime) && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && (
<div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
</div>
)}
</div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const parts: string[] = []
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
else if (meta.flight_number) parts.push(meta.flight_number)
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport}${meta.arrival_airport}`)
if (meta.train_number) parts.push(meta.train_number)
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
if (parts.length === 0) return null
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</div>
})()}
</div>
)
})()}
{/* Participants */}
{showParticipants && (
<ParticipantsBox
tripMembers={tripMembers}
participantIds={participantIds}
allJoined={allJoined}
onSetParticipants={onSetParticipants}
selectedAssignmentId={selectedAssignmentId}
selectedDayId={selectedDayId}
t={t}
/>
)}
</div>
)
})()}
</>
)
}
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: any) {
return (
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button
onClick={() => setHoursExpanded(h => !h)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Clock size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
</span>
</div>
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
</button>
{hoursExpanded && (
<div style={{ padding: '0 12px 10px' }}>
{openingHours.map((line, i) => (
<div key={i} style={{
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: i === weekdayIndex ? 600 : 400,
padding: '2px 0',
}}>{convertHoursLine(line, timeFormat)}</div>
))}
</div>
)}
</div>
)}
{/* GPX Track stats */}
{place.route_geometry && (() => {
try {
const pts: number[][] = JSON.parse(place.route_geometry)
if (!pts || pts.length < 2) return null
const hasEle = pts[0].length >= 3
// Haversine distance
const toRad = (d: number) => d * Math.PI / 180
let totalDist = 0
for (let i = 1; i < pts.length; i++) {
const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i]
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
const distKm = totalDist / 1000
// Elevation stats
let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0
if (hasEle) {
for (let i = 0; i < pts.length; i++) {
const e = pts[i][2]
if (e < minEle) minEle = e
if (e > maxEle) maxEle = e
if (i > 0) {
const diff = e - pts[i - 1][2]
if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff)
}
}
}
// Elevation profile SVG
const chartW = 280, chartH = 60
const elevations = hasEle ? pts.map(p => p[2]) : []
let pathD = ''
if (elevations.length > 1) {
const step = Math.max(1, Math.floor(elevations.length / chartW))
const sampled = elevations.filter((_, i) => i % step === 0)
const eMin = Math.min(...sampled), eMax = Math.max(...sampled)
const range = eMax - eMin || 1
pathD = sampled.map((e, i) => {
const x = (i / (sampled.length - 1)) * chartW
const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
return (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{Math.round(totalUp)} m &nbsp;{Math.round(totalDown)} m
</div>
</>
)}
</div>
{pathD && (
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<defs>
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)}
</div>
)
} catch { return null }
})()}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button
onClick={() => setFilesExpanded(f => !f)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
>
<FileText size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
</button>
{onFileUpload && (
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
{isUploading ? (
<span style={{ fontSize: 11 }}></span>
) : (
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
)}
</label>
)}
</div>
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<button key={f.id} onClick={() => openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
</button>
))}
</div>
)}
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -27,7 +27,7 @@ beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
});
describe('ReservationsPanel', () => {
@@ -211,7 +211,7 @@ describe('ReservationsPanel', () => {
});
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const codeEl = screen.getByText('ABC123');
@@ -220,7 +220,7 @@ describe('ReservationsPanel', () => {
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const codeEl = screen.getByText('ABC123');
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
expect(screen.getByText('Pending 2')).toBeInTheDocument();
expect(screen.getByText('Pending 3')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
const day = buildDay({ date: null, day_number: 25 } as any);
const r = buildReservation({
title: 'Cruise test',
type: 'cruise',
status: 'pending',
reservation_time: 'T10:00',
reservation_end_time: 'T18:00',
day_id: day.id,
end_day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/10:00/)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
const day = buildDay({ date: null, day_number: 3 } as any);
const r = buildReservation({
title: 'Car rental',
type: 'car',
status: 'pending',
reservation_time: '09:00',
reservation_end_time: '17:00',
day_id: day.id,
end_day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/09:00/)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
const day = buildDay({ date: '2026-07-15', day_number: 1 });
const r = buildReservation({
title: 'Flight out',
type: 'flight',
status: 'confirmed',
reservation_time: '2026-07-15T08:30',
reservation_end_time: '2026-07-15T10:45',
day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/08:30/)).toBeInTheDocument();
});
});
@@ -15,6 +15,7 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
interface AssignmentLookupEntry {
dayNumber: number
@@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const fmtDate = (str) => {
const dateOnly = str.includes('T') ? str.split('T')[0] : str
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
}
const fmtTime = (str) => {
const d = new Date(str)
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
}
const startDt = splitReservationDateTime(r.reservation_time)
const endDt = splitReservationDateTime(r.reservation_end_time)
const fmtDate = (date: string) =>
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
const hasDate = !!r.reservation_time
const hasTime = r.reservation_time?.includes('T')
const hasDate = !!startDt.date
const hasTime = !!(startDt.time || endDt.time)
const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
)}
{/* Date / Time row */}
{hasDate && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtDate(r.reservation_time)}
{(() => {
const endDatePart = r.reservation_end_time
? r.reservation_end_time.includes('T')
? r.reservation_end_time.split('T')[0]
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
? r.reservation_end_time
: null
: null
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
})() && (
<> {fmtDate(r.reservation_end_time)}</>
)}
{(hasDate || hasTime) && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
{hasDate && (
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtDate(startDt.date!)}
{endDt.date && endDt.date !== startDt.date && (
<> {fmtDate(endDt.date)}</>
)}
</div>
</div>
</div>
)}
{hasTime && (
<div>
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
{formatTime(startDt.time, locale, timeFormat)}
{endDt.time ? ` ${formatTime(endDt.time, locale, timeFormat)}` : ''}
</div>
</div>
)}
@@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (cells.length === 0) return null
return (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters'
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
@@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
status: reservation.status || 'pending',
start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '',
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
meta_airline: meta.airline || '',
@@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null
return day?.date ? `${day.date}T${time}` : `T${time}`
return day?.date ? `${day.date}T${time}` : time
}
const metadata: Record<string, string> = {}
@@ -0,0 +1,102 @@
import { useState, useEffect } from 'react'
import { weatherApi, accommodationsApi } from '../../api/client'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
/** Day-detail data + accommodation logic: weather load, accommodations list,
* hotel picker form state and create/update/delete handlers. */
export function useDayDetail(day: any, days: any, tripId: any, lat: any, lng: any, language: any, onAccommodationChange: any) {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null)
const [dayAccommodations, setDayAccommodations] = useState<any[]>([])
const [accommodations, setAccommodations] = useState([])
const [showHotelPicker, setShowHotelPicker] = useState(false)
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
useEffect(() => {
if (!day?.date || !lat || !lng) { setWeather(null); return }
setLoading(true)
weatherApi.getDetailed(lat, lng, day.date, language)
.then(data => setWeather(data.error ? null : data))
.catch(() => setWeather(null))
.finally(() => setLoading(false))
}, [day?.date, lat, lng, language])
useEffect(() => {
if (!tripId) return
accommodationsApi.list(tripId)
.then(data => {
setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
)
setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
})
.catch(() => {})
}, [tripId, day?.id])
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
const handleSelectPlace = (placeId) => {
setHotelForm(f => ({ ...f, place_id: placeId }))
}
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
check_in_end: hotelForm.check_in_end || null,
check_out: hotelForm.check_out || null,
confirmation: hotelForm.confirmation || null,
})
const newAcc = data.accommodation
const updated = [...accommodations, newAcc]
setAccommodations(updated)
setAccommodation(newAcc)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
onAccommodationChange?.()
} catch {}
}
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
}
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setAccommodation(null)
onAccommodationChange?.()
} catch {}
}
return {
weather, loading, accommodation, setAccommodation, dayAccommodations, setDayAccommodations,
accommodations, setAccommodations, showHotelPicker, setShowHotelPicker,
hotelDayRange, setHotelDayRange, hotelCategoryFilter, setHotelCategoryFilter,
hotelForm, setHotelForm, handleSelectPlace, handleSaveAccommodation,
updateAccommodationField, handleRemoveAccommodation,
}
}
@@ -161,29 +161,6 @@ describe('DisplaySettingsTab', () => {
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
});
it('FE-COMP-DISPLAY-021: shows Route Calculation section', () => {
render(<DisplaySettingsTab />);
expect(screen.getByText(/route calculation/i)).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-022: route calculation On button is active when route_calculation is true', () => {
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }) });
render(<DisplaySettingsTab />);
const onButtons = screen.getAllByText(/^On$/i);
const routeCalcOnBtn = onButtons[0].closest('button')!;
expect(routeCalcOnBtn.style.border).toContain('var(--text-primary)');
});
it('FE-COMP-DISPLAY-023: clicking route calculation Off calls updateSetting with false', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }), updateSetting });
render(<DisplaySettingsTab />);
const offButtons = screen.getAllByText(/^Off$/i);
await user.click(offButtons[0]);
expect(updateSetting).toHaveBeenCalledWith('route_calculation', false);
});
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
render(<DisplaySettingsTab />);
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
@@ -214,36 +214,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
</div>
{/* Route Calculation */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Booking route labels */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
@@ -69,6 +69,7 @@ interface OAuthClient {
client_id: string
redirect_uris: string[]
allowed_scopes: string[]
allows_client_credentials: boolean
created_at: string
client_secret?: string // only present on create
}
@@ -92,6 +93,18 @@ interface McpToken {
}
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
return (
<>
<PhotoProvidersSection />
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
</>
)
}
function useIntegrations() {
const { t, locale } = useTranslation()
const toast = useToast()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
@@ -117,6 +130,7 @@ export default function IntegrationsTab(): React.ReactElement {
const [oauthRotating, setOauthRotating] = useState(false)
// oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
const [oauthIsMachine, setOauthIsMachine] = useState(false)
// MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
@@ -214,16 +228,23 @@ export default function IntegrationsTab(): React.ReactElement {
}, [mcpEnabled])
const handleCreateOAuthClient = async () => {
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
if (!oauthNewName.trim()) return
if (!oauthIsMachine && !oauthNewUris.trim()) return
setOauthCreating(true)
try {
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
const d = await oauthApi.clients.create({
name: oauthNewName.trim(),
redirect_uris: uris,
allowed_scopes: oauthNewScopes,
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
})
setOauthCreatedClient(d.client)
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
setOauthNewName('')
setOauthNewUris('')
setOauthNewScopes([])
setOauthIsMachine(false)
} catch {
toast.error(t('settings.oauth.toast.createError'))
} finally {
@@ -266,10 +287,17 @@ export default function IntegrationsTab(): React.ReactElement {
}
}
return {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
}
}
function IntegrationsMcpSection(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
<PhotoProvidersSection />
{mcpEnabled && (
<Section title={t('settings.mcp.title')} icon={Terminal}>
{/* Endpoint URL */}
<div>
@@ -342,7 +370,7 @@ export default function IntegrationsTab(): React.ReactElement {
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
<div className="flex justify-end mb-2">
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</button>
@@ -360,7 +388,15 @@ export default function IntegrationsTab(): React.ReactElement {
<div className="flex items-center gap-3">
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
{client.allows_client_credentials && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
{t('settings.oauth.badge.machine')}
</span>
)}
</div>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.clientId')}: {client.client_id}
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
@@ -498,8 +534,15 @@ export default function IntegrationsTab(): React.ReactElement {
</>
)}
</Section>
)}
)
}
function McpTokenModals(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
{/* Create MCP Token modal */}
{mcpModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
@@ -578,6 +621,16 @@ export default function IntegrationsTab(): React.ReactElement {
</div>
)}
</>
)
}
function OAuthClientModals(props: any) {
const {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
} = props
return (
<>
{/* Create OAuth Client modal */}
{oauthCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
@@ -616,15 +669,26 @@ export default function IntegrationsTab(): React.ReactElement {
autoFocus />
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div>
<label className="flex items-start gap-2.5 cursor-pointer">
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
<div>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
</div>
</label>
{!oauthIsMachine && (
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
@@ -638,7 +702,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')}
</button>
<button onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
</button>
@@ -681,6 +745,12 @@ export default function IntegrationsTab(): React.ReactElement {
</div>
</div>
{oauthCreatedClient?.allows_client_credentials && (
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
{t('settings.oauth.modal.machineClientUsage')}
</div>
)}
<div className="flex justify-end">
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
@@ -303,7 +303,14 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
);
}
export function ModalRenderer({ notices }: Props) {
/**
* Drives the system-notice modal: pager index + visibility, mobile/dark/reduced-
* motion detection, body-scroll lock, keyboard (ESC + arrows) and the page-slide
* animation refs. Exposes dismiss/CTA/pager handlers + the touch-drag refs used
* by the mobile bottom sheet. The two layout components below render from it.
*/
function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
const [idx, setIdx] = useState(0);
const [visible, setVisible] = useState(false);
const [pageAnnouncement, setPageAnnouncement] = useState('');
@@ -568,236 +575,231 @@ export function ModalRenderer({ notices }: Props) {
announceIndex(i, notices.length);
}
// No notice to show
if (!notice) return null;
// Pre-compute body with params interpolated
const rawBody = t(notice.bodyKey);
const body = notice.bodyParams
? Object.entries(notice.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
rawBody
)
: rawBody;
const title = t(notice.titleKey);
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
const titleId = `notice-title-${notice.id}`;
const bodyId = `notice-body-${notice.id}`;
// Animation classes
const dur = prefersReducedMotion ? 'duration-[120ms]' : 'duration-[260ms]';
const ease = visible ? 'ease-out' : 'ease-in';
const contentProps: ContentProps = {
notice, title, body, ctaLabel, titleId, bodyId, isDark,
return {
notices, idx, setIdx, visible, pageAnnouncement, isMobile, isDark, prefersReducedMotion,
notice, canPage, isLastPage, language, t, dur, ease,
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll,
handlePrev, handleNext, handleGoto,
};
}
type NoticeState = ReturnType<typeof useSystemNoticeModal>;
// Build the NoticeContent props for a given notice + pager slot index.
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handlePrev, handleNext, handleGoto } = S;
const rawBody = t(n.bodyKey);
const body = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
rawBody
)
: rawBody;
return {
notice: n,
title: t(n.titleKey),
body,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: idx,
currentPage: slotIdx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
}
if (isMobile) {
const mobileMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
function MobileNoticeSheet(S: NoticeState) {
const {
notice, idx, notices, visible, dur, ease, prefersReducedMotion, pageAnnouncement,
language, canPage, setIdx, announceIndex, isPageNavRef, animatedDismissAll,
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart,
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
} = S;
if (!notice) return null;
const titleId = `notice-title-${notice.id}`;
const bodyId = `notice-body-${notice.id}`;
const mobileMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
const slotRawBody = t(n.bodyKey);
const slotBody = n.bodyParams
? Object.entries(n.bodyParams).reduce(
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
slotRawBody
)
: slotRawBody;
return {
notice: n,
title: t(n.titleKey),
body: slotBody,
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
titleId: `notice-title-${n.id}`,
bodyId: `notice-body-${n.id}`,
isDark,
onDismiss: handleDismiss,
onDismissAll: handleDismissAll,
onCTA: handleCTA,
total: notices.length,
currentPage: slotIdx,
canPage,
onPrev: handlePrev,
onNext: handleNext,
onGoto: handleGoto,
};
}
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
const prevNotice = notices[idx - 1] ?? null;
const nextNotice = notices[idx + 1] ?? null;
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
return (
<div className="fixed inset-0 z-50" role="presentation">
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
onClick={notice.dismissible ? animatedDismissAll : undefined}
/>
{/* Bottom sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
style={{ touchAction: 'pan-y' }}
onTouchStart={e => {
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
dragLockRef.current = null;
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
}}
onTouchMove={e => {
if (prefersReducedMotion) return;
const startX = touchStartX.current;
const startY = touchStartY.current;
if (startX === null || startY === null) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
// Classify gesture direction on first significant movement
if (!dragLockRef.current) {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
// Reset adjacent slots to top before they slide into view.
if (dragLockRef.current === 'h') {
prevSlotRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
}
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
return;
}
if (dragLockRef.current === 'h') {
const strip = stripRef.current;
if (!strip) return;
strip.style.transition = 'none';
// Strip base = -33.333% (center slot visible); dx offsets from there
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
} else if (dragLockRef.current === 'v' && notice.dismissible) {
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
// If scrolled into content, let native pan-y scroll it back up.
if (scrollTopAtTouchStart.current > 0) return;
const sheet = sheetRef.current;
if (!sheet || dy <= 0) return;
sheet.style.transition = 'none';
sheet.style.transform = `translateY(${dy}px)`;
}
}}
onTouchEnd={e => {
const startX = touchStartX.current;
const startY = touchStartY.current;
touchStartX.current = null;
touchStartY.current = null;
const lock = dragLockRef.current;
dragLockRef.current = null;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
if (lock === 'h') {
if (startX === null) return;
const deltaX = e.changedTouches[0].clientX - startX;
const strip = stripRef.current;
if (!strip) return;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
const canGoNext = canPage && idx < notices.length - 1;
const canGoPrev = canPage && idx > 0;
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
strip.style.transition = 'transform 200ms ease-out';
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
strip.addEventListener('transitionend', function onDone() {
strip.removeEventListener('transitionend', onDone);
strip.style.transition = 'none';
// Render new content into the center slot BEFORE moving the strip,
// so the browser never paints old content at the center position.
const newIdx = goNext ? idx + 1 : idx - 1;
flushSync(() => {
isPageNavRef.current = true;
setIdx(newIdx);
announceIndex(newIdx, notices.length);
});
// Reset all slot scrolls so the new center starts at top.
prevSlotRef.current?.scrollTo({ top: 0 });
contentWrapperRef.current?.scrollTo({ top: 0 });
nextSlotRef.current?.scrollTo({ top: 0 });
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}, { once: true });
} else {
// Spring back to center
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
strip.style.transform = 'translateX(-33.333%)';
strip.addEventListener('transitionend', function onSnap() {
strip.removeEventListener('transitionend', onSnap);
strip.style.transition = '';
strip.style.transform = 'translateX(-33.333%)';
}, { once: true });
}
return;
}
// Vertical drag — animated dismiss or spring back (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
// Vertical drag — animated dismiss or spring back (only when at scroll top)
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
const deltaY = e.changedTouches[0].clientY - startY;
const sheet = sheetRef.current;
if (deltaY > 80 && notice.dismissible) {
animatedDismissAll();
} else if (sheet && deltaY > 0) {
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
sheet.style.transform = 'translateY(0)';
sheet.addEventListener('transitionend', function onSnap() {
sheet.removeEventListener('transitionend', onSnap);
sheet.style.transition = '';
sheet.style.transform = '';
}, { once: true });
}
}}
>
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...contentProps} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
</div>
}
}}
>
{/* Drag handle — fixed, does not scroll */}
<div className="pt-3 pb-1 flex justify-center shrink-0">
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
</div>
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
<div
ref={stripRef}
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
>
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{prevNotice && <NoticeContent {...makeContentProps(S, prevNotice, idx - 1)} />}
</div>
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<NoticeContent {...makeContentProps(S, notice, idx)} />
</div>
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{nextNotice && <NoticeContent {...makeContentProps(S, nextNotice, idx + 1)} />}
</div>
</div>
</div>
</div>
);
}
</div>
);
}
// Desktop centered modal
function DesktopNoticeModal(S: NoticeState) {
const { notice, idx, visible, dur, ease, prefersReducedMotion, pageAnnouncement, isLastPage, handleDismissAll, contentWrapperRef } = S;
if (!notice) return null;
const titleId = `notice-title-${notice.id}`;
const bodyId = `notice-body-${notice.id}`;
const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]';
const desktopMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
@@ -821,10 +823,17 @@ export function ModalRenderer({ notices }: Props) {
onClick={e => e.stopPropagation()}
>
<div ref={contentWrapperRef}>
<NoticeContent {...contentProps} />
<NoticeContent {...makeContentProps(S, notice, idx)} />
</div>
</div>
</div>
</div>
);
}
export function ModalRenderer({ notices }: Props) {
const S = useSystemNoticeModal(notices);
// No notice to show
if (!S.notice) return null;
return S.isMobile ? <MobileNoticeSheet {...S} /> : <DesktopNoticeModal {...S} />;
}
+27 -206
View File
@@ -15,111 +15,21 @@ import {
} from 'lucide-react'
import type { TodoItem } from '../../types'
const KAT_COLORS = [
'#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316',
'#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6',
]
const PRIO_CONFIG: Record<number, { label: string; color: string }> = {
1: { label: 'P1', color: '#ef4444' },
2: { label: 'P2', color: '#f59e0b' },
3: { label: 'P3', color: '#3b82f6' },
}
function katColor(kat: string, allCategories: string[]) {
const idx = allCategories.indexOf(kat)
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
let h = 0
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
interface Member { id: number; username: string; avatar: string | null }
import { KAT_COLORS, PRIO_CONFIG, katColor, type FilterType, type Member } from './todoListModel'
import { useTodoList } from './useTodoList'
import TodoRow from './TodoRow'
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
const { t, locale } = useTranslation()
const formatDate = (d: string) => fmtDate(d, locale) || d
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const lastHandledAddSignal = useRef(addItemSignal)
useEffect(() => {
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
setSelectedId(null)
setIsAddingNew(true)
}
lastHandledAddSignal.current = addItemSignal
}, [addItemSignal])
const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [members, setMembers] = useState<Member[]>([])
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
useEffect(() => {
apiClient.get(`/trips/${tripId}/members`).then(r => {
const owner = r.data?.owner
const mems = r.data?.members || []
const all = owner ? [owner, ...mems] : mems
setMembers(all)
setCurrentUserId(r.data?.current_user_id || null)
}).catch(() => {})
}, [tripId])
const categories = useMemo(() => {
const cats = new Set<string>()
items.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [items])
const today = new Date().toISOString().split('T')[0]
const filtered = useMemo(() => {
let result: TodoItem[]
if (filter === 'all') result = items.filter(i => !i.checked)
else if (filter === 'done') result = items.filter(i => !!i.checked)
else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId)
else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today)
else result = items.filter(i => i.category === filter)
if (sortByPrio) result = [...result].sort((a, b) => {
const ap = a.priority || 99
const bp = b.priority || 99
return ap - bp
})
return result
}, [items, filter, currentUserId, today, sortByPrio])
const selectedItem = items.find(i => i.id === selectedId) || null
const totalCount = items.length
const doneCount = items.filter(i => !!i.checked).length
const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length
const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0
const addCategory = () => {
const name = newCategoryName.trim()
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
.catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
}
// Get category count (non-done items)
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
// Layout component: state/effects/derived/handlers live in useTodoList.
const {
canEdit, t, formatDate, toggleTodoItem,
isMobile, filter, setFilter, selectedId, setSelectedId,
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
members, categories, today, filtered, selectedItem,
totalCount, doneCount, overdueCount, myCount,
addCategory, catCount,
} = useTodoList(tripId, items, addItemSignal)
// Sidebar filter item
const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => (
@@ -267,109 +177,20 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
{/* Task list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 ? null : (
filtered.map(item => {
const done = !!item.checked
const assignedUser = members.find(m => m.id === item.assigned_user_id)
const isOverdue = item.due_date && !done && item.due_date < today
const isSelected = selectedId === item.id
const catColor = item.category ? katColor(item.category, categories) : null
return (
<div key={item.id}
onClick={() => { setSelectedId(isSelected ? null : item.id); setIsAddingNew(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
background: isSelected ? 'var(--bg-hover)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{/* Checkbox */}
<button onClick={e => { e.stopPropagation(); canEdit && toggleTodoItem(tripId, item.id, !done) }}
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
color: done ? '#22c55e' : 'var(--border-primary)' }}>
{done ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
{/* Description preview */}
{item.description && (
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
{item.description}
</div>
)}
{/* Inline badges */}
{(item.priority || item.due_date || catColor || assignedUser) && (
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
color: PRIO_CONFIG[item.priority].color,
background: `${PRIO_CONFIG[item.priority].color}10`,
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
}}>
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
</span>
)}
{item.due_date && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
}}>
<Calendar size={9} />{formatDate(item.due_date)}
</span>
)}
{catColor && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
{item.category}
</span>
)}
{assignedUser && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
{assignedUser.avatar ? (
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
) : (
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
{assignedUser.username.charAt(0).toUpperCase()}
</span>
)}
{assignedUser.username}
</span>
)}
</div>
)}
</div>
{/* Chevron */}
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
</div>
)
})
filtered.map(item => (
<TodoRow
key={item.id}
item={item}
members={members}
categories={categories}
today={today}
isSelected={selectedId === item.id}
canEdit={canEdit}
formatDate={formatDate}
onSelect={(id) => { setSelectedId(id); setIsAddingNew(false) }}
onToggle={(id, checked) => toggleTodoItem(tripId, id, checked)}
/>
))
)}
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
import { CheckSquare, Square, ChevronRight, Flag, Calendar } from 'lucide-react'
import type { TodoItem } from '../../types'
import { katColor, PRIO_CONFIG, type Member } from './todoListModel'
/** A single task row in the todo list. Pure presentation; all behaviour is
* delegated to onSelect/onToggle so TodoListPanel stays a layout component. */
export default function TodoRow({ item, members, categories, today, isSelected, canEdit, formatDate, onSelect, onToggle }: {
item: TodoItem
members: Member[]
categories: string[]
today: string
isSelected: boolean
canEdit: boolean
formatDate: (d: string) => string
onSelect: (id: number | null) => void
onToggle: (id: number, checked: boolean) => void
}) {
const done = !!item.checked
const assignedUser = members.find(m => m.id === item.assigned_user_id)
const isOverdue = item.due_date && !done && item.due_date < today
const catColor = item.category ? katColor(item.category, categories) : null
return (
<div key={item.id}
onClick={() => onSelect(isSelected ? null : item.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px',
borderBottom: '1px solid var(--border-faint)', cursor: 'pointer',
background: isSelected ? 'var(--bg-hover)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}>
{/* Checkbox */}
<button onClick={e => { e.stopPropagation(); if (canEdit) onToggle(item.id, !done) }}
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
color: done ? '#22c55e' : 'var(--border-primary)' }}>
{done ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
{/* Description preview */}
{item.description && (
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
{item.description}
</div>
)}
{/* Inline badges */}
{(item.priority || item.due_date || catColor || assignedUser) && (
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
color: PRIO_CONFIG[item.priority].color,
background: `${PRIO_CONFIG[item.priority].color}10`,
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
}}>
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
</span>
)}
{item.due_date && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
}}>
<Calendar size={9} />{formatDate(item.due_date)}
</span>
)}
{catColor && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
{item.category}
</span>
)}
{assignedUser && (
<span style={{
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
border: '1px solid var(--border-faint)',
}}>
{assignedUser.avatar ? (
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
) : (
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
{assignedUser.username.charAt(0).toUpperCase()}
</span>
)}
{assignedUser.username}
</span>
)}
</div>
)}
</div>
{/* Chevron */}
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
</div>
)
}
@@ -0,0 +1,24 @@
// Pure constants + helpers + types for the todo list. No React, no side effects.
export const KAT_COLORS = [
'#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316',
'#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6',
]
export const PRIO_CONFIG: Record<number, { label: string; color: string }> = {
1: { label: 'P1', color: '#ef4444' },
2: { label: 'P2', color: '#f59e0b' },
3: { label: 'P3', color: '#3b82f6' },
}
export function katColor(kat: string, allCategories: string[]) {
const idx = allCategories.indexOf(kat)
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
let h = 0
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
}
export type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
export interface Member { id: number; username: string; avatar: string | null }
+111
View File
@@ -0,0 +1,111 @@
import { useState, useMemo, useEffect, useRef } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import apiClient from '../../api/client'
import { formatDate as fmtDate } from '../../utils/formatters'
import type { TodoItem } from '../../types'
import type { FilterType, Member } from './todoListModel'
/**
* Todo list logic store actions, member load, the filter/selection/add-new
* view state and the derived buckets (filtered list + counts) + handlers.
* TodoListPanel stays a layout component that renders the sidebar, the rows
* (TodoRow) and the detail/new panes from this state.
*/
export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: number) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
const { t, locale } = useTranslation()
const formatDate = (d: string) => fmtDate(d, locale) || d
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const lastHandledAddSignal = useRef(addItemSignal)
useEffect(() => {
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
setSelectedId(null)
setIsAddingNew(true)
}
lastHandledAddSignal.current = addItemSignal
}, [addItemSignal])
const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [members, setMembers] = useState<Member[]>([])
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
useEffect(() => {
apiClient.get(`/trips/${tripId}/members`).then(r => {
const owner = r.data?.owner
const mems = r.data?.members || []
const all = owner ? [owner, ...mems] : mems
setMembers(all)
setCurrentUserId(r.data?.current_user_id || null)
}).catch(() => {})
}, [tripId])
const categories = useMemo(() => {
const cats = new Set<string>()
items.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [items])
const today = new Date().toISOString().split('T')[0]
const filtered = useMemo(() => {
let result: TodoItem[]
if (filter === 'all') result = items.filter(i => !i.checked)
else if (filter === 'done') result = items.filter(i => !!i.checked)
else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId)
else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today)
else result = items.filter(i => i.category === filter)
if (sortByPrio) result = [...result].sort((a, b) => {
const ap = a.priority || 99
const bp = b.priority || 99
return ap - bp
})
return result
}, [items, filter, currentUserId, today, sortByPrio])
const selectedItem = items.find(i => i.id === selectedId) || null
const totalCount = items.length
const doneCount = items.filter(i => !!i.checked).length
const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length
const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0
const addCategory = () => {
const name = newCategoryName.trim()
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
.catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
}
// Get category count (non-done items)
const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length
return {
canEdit, t, formatDate, toggleTodoItem,
isMobile, filter, setFilter, selectedId, setSelectedId,
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
members, categories, today, filtered, selectedItem,
totalCount, doneCount, overdueCount, myCount,
addCategory, catCount,
// exposed for completeness (DetailPane/NewTaskPane already get their own)
updateTodoItem, deleteTodoItem,
}
}
@@ -42,9 +42,11 @@ interface WeatherWidgetProps {
lng: number | null
date: string
compact?: boolean
/** Vertical icon-over-temp layout that inherits its color (for the day badge). */
stacked?: boolean
}
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
@@ -111,6 +113,15 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
const unit = isFahrenheit ? '°F' : '°C'
const isClimate = weather.type === 'climate'
if (stacked) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 9.5, fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
<WeatherIcon main={weather.main} size={13} />
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
</div>
)
}
if (compact) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
@@ -1,4 +1,5 @@
import React, { useEffect, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -38,7 +39,7 @@ export default function ConfirmDialog({
if (!isOpen) return null
return (
return ReactDOM.createPortal(
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
@@ -87,6 +88,7 @@ export default function ConfirmDialog({
</div>
</div>
</div>
</div>,
document.body
)
}
@@ -1,4 +1,5 @@
import React, { useEffect, useCallback } from 'react'
import ReactDOM from 'react-dom'
import { Check, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -39,7 +40,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
if (!isOpen) return null
return (
return ReactDOM.createPortal(
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
@@ -97,12 +98,14 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
className="px-4 py-2 text-sm font-medium rounded-lg transition-opacity hover:opacity-90"
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
>
{t('dashboard.confirm.copy.confirm')}
</button>
</div>
</div>
</div>
</div>,
document.body
)
}
+4 -2
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useCallback, useRef } from 'react'
import ReactDOM from 'react-dom'
import { X } from 'lucide-react'
const sizeClasses: Record<string, string> = {
@@ -48,7 +49,7 @@ export default function Modal({
if (!isOpen) return null
return (
return ReactDOM.createPortal(
<div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
@@ -94,6 +95,7 @@ export default function Modal({
)}
</div>
</div>
</div>,
document.body
)
}
+13 -1
View File
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const imageUrlFailed = useRef(false)
const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
alt={place.name}
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
onError={() => {
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
imageUrlFailed.current = true
const photoId = place.google_place_id || place.osm_id!
const cacheKey = `refetch:${photoId}`
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
)
} else {
setPhotoSrc(null)
}
}}
/>
</div>
)
+33
View File
@@ -0,0 +1,33 @@
import React from 'react'
interface SpinnerProps {
/**
* Tailwind classes controlling size + colours of the ring. Callers keep full
* control so adopting the component never changes a spinner's appearance.
* Defaults to the app's most common page-loader ring.
*/
className?: string
}
/** The bare spinning ring used throughout the app (`rounded-full animate-spin`). */
export function Spinner({ className = 'w-6 h-6 border-2 border-zinc-300 border-t-zinc-900' }: SpinnerProps): React.ReactElement {
return <div className={`${className} rounded-full animate-spin`} />
}
interface PageSpinnerProps extends SpinnerProps {
/** Wrapper classes for the centring container. */
wrapperClassName?: string
wrapperStyle?: React.CSSProperties
}
/**
* A full-area centred loading spinner the repeated "flex items-center
* justify-center" loader that page loading-guards render while data resolves.
*/
export function PageSpinner({ className, wrapperClassName = 'flex items-center justify-center', wrapperStyle }: PageSpinnerProps): React.ReactElement {
return (
<div className={wrapperClassName} style={wrapperStyle}>
<Spinner className={className} />
</div>
)
}
+40 -25
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import { useTripStore } from '../store/tripStore'
import { calculateSegments } from '../components/Map/RouteCalculator'
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types'
@@ -9,20 +8,20 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route, and optionally fetches per-segment
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return }
// Route is manual: only compute when explicitly enabled (the "show route" toggle).
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); return }
// Read directly from store (not a render-phase ref) so callers after optimistic
// updates or non-optimistic deletes always see the latest assignments.
const currentAssignments = useTripStore.getState().assignments || {}
@@ -67,35 +66,51 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
})),
].sort((a, b) => a.pos - b.pos)
const segments: [number, number][][] = []
let currentSeg: [number, number][] = []
// Group consecutive located places into runs, resetting whenever a transport
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
const runs: { lat: number; lng: number }[][] = []
let currentRun: { lat: number; lng: number }[] = []
for (const entry of entries) {
if (entry.kind === 'place') {
currentSeg.push([entry.lat, entry.lng])
currentRun.push({ lat: entry.lat, lng: entry.lng })
} else {
if (currentSeg.length >= 2) segments.push([...currentSeg])
currentSeg = []
if (currentRun.length >= 2) runs.push(currentRun)
currentRun = []
}
}
if (currentSeg.length >= 2) segments.push(currentSeg)
if (currentRun.length >= 2) runs.push(currentRun)
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
const straightLines = (): [number, number][][] =>
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
// OSRM road geometry.
setRoute(straightLines())
if (segments.length === 0 && geocodedWaypoints.length < 2) {
setRoute(null); setRouteSegments([]); return
}
setRoute(segments.length > 0 ? segments : null)
if (!routeCalcEnabled) { setRouteSegments([]); return }
const controller = new AbortController()
routeAbortRef.current = controller
try {
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
if (!controller.signal.aborted) setRouteSegments(calcSegments)
const polylines: [number, number][][] = []
const allLegs: RouteSegment[] = []
for (const run of runs) {
try {
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
allLegs.push(...r.legs)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
// OSRM failed for this run — fall back to a straight line, no times.
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
}
}
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) }
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
else if (!(err instanceof Error)) setRouteSegments([])
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
}
}, [routeCalcEnabled])
}, [enabled, profile])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -117,7 +132,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+58
View File
@@ -0,0 +1,58 @@
import type { JSX } from 'react'
import { useTranslation } from './TranslationContext'
interface TransHtmlProps<T extends keyof JSX.IntrinsicElements = 'span'> {
/**
* Translation key whose template legitimately contains markup (e.g.
* `'Turn <strong>{title}</strong> into a Journey'`).
*/
html: string
/**
* Values to interpolate into `{paramName}` placeholders. Every value is
* HTML-escaped before substitution, so passing user-controlled data is safe.
*/
params?: Record<string, string | number>
/**
* Element to render. Defaults to `<span>`. Use the tag that fits the
* surrounding flow block, inline, list item, etc.
*/
as?: T
className?: string
/**
* `id` is forwarded so the component can be the target of `aria-labelledby`
* or `htmlFor`. Other ARIA attributes can be added if needed; we intentionally
* keep the surface small to discourage overloading this with arbitrary props.
*/
id?: string
}
/**
* Renders a translation that contains markup (e.g. `<strong>`) safely.
*
* Replaces the pattern that bit us in the Journey suggestion banner:
* <span dangerouslySetInnerHTML={{ __html: t('...', { user_input }) }} />
*
* That pattern interpolates `user_input` into the template *before* React
* ever sees it, so a trip title like `<script>alert(1)</script>` would inject
* a script tag. `TransHtml` runs `tHtml()` which:
*
* 1. HTML-escapes every interpolated value, neutralising it.
* 2. Sanitises the resulting string against an inline tag allow-list.
*
* Use this for any user-controlled value that lands in a markup template.
* Plain text-only templates can continue to use `<>{t('key', params)}</>`.
*/
export function TransHtml<T extends keyof JSX.IntrinsicElements = 'span'>({
html,
params,
as,
className,
id,
}: TransHtmlProps<T>) {
const { tHtml } = useTranslation()
const Tag = (as ?? 'span') as keyof JSX.IntrinsicElements
return (
// eslint-disable-next-line react/no-danger -- sanitised by tHtml (defence in depth)
<Tag className={className} id={id} dangerouslySetInnerHTML={{ __html: tHtml(html, params) }} />
)
}
+95 -55
View File
@@ -1,52 +1,50 @@
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
import React, { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
import es from './translations/es'
import fr from './translations/fr'
import hu from './translations/hu'
import it from './translations/it'
import ru from './translations/ru'
import zh from './translations/zh'
import zhTw from './translations/zhTw'
import nl from './translations/nl'
import id from './translations/id'
import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
import en from '@trek/shared/i18n/en'
import type { SupportedLanguageCode } from '@trek/shared'
import {
SUPPORTED_LANGUAGES,
getLocaleForLanguage,
getIntlLanguage,
isRtlLanguage,
escapeHtml,
sanitizeInlineHtml,
} from '@trek/shared'
import type { TranslationStrings } from '@trek/shared/i18n'
export { SUPPORTED_LANGUAGES }
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
// One explicit dynamic import per locale — Vite code-splits a separate chunk per locale.
// Only the active locale is fetched; en is always available synchronously as the fallback.
const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: TranslationStrings }>> = {
en: () => Promise.resolve({ default: en }),
de: () => import('@trek/shared/i18n/de'),
es: () => import('@trek/shared/i18n/es'),
fr: () => import('@trek/shared/i18n/fr'),
hu: () => import('@trek/shared/i18n/hu'),
it: () => import('@trek/shared/i18n/it'),
tr: () => import('@trek/shared/i18n/tr'),
ru: () => import('@trek/shared/i18n/ru'),
zh: () => import('@trek/shared/i18n/zh'),
'zh-TW': () => import('@trek/shared/i18n/zh-TW'),
nl: () => import('@trek/shared/i18n/nl'),
id: () => import('@trek/shared/i18n/id'),
ar: () => import('@trek/shared/i18n/ar'),
br: () => import('@trek/shared/i18n/br'),
cs: () => import('@trek/shared/i18n/cs'),
pl: () => import('@trek/shared/i18n/pl'),
ja: () => import('@trek/shared/i18n/ja'),
ko: () => import('@trek/shared/i18n/ko'),
uk: () => import('@trek/shared/i18n/uk'),
gr: () => import('@trek/shared/i18n/gr'),
}
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
const LOCALES: Record<string, string> = Object.fromEntries(
SUPPORTED_LANGUAGES.map(l => [l.value, l.locale])
)
const RTL_LANGUAGES = new Set(['ar'])
// Re-export pure helpers that live in shared so downstream consumers can import them
// through this module without changing their import path.
export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage }
export function getLocaleForLanguage(language: string): string {
return LOCALES[language] || LOCALES.en
}
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
return RTL_LANGUAGES.has(language)
}
// Detects the user's preferred language from the browser/OS settings and maps
// it to one of the supported language codes. Returns null if no match is found.
// Detects the user's preferred language from browser/OS settings.
// Returns null if no supported language matches.
export function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const browserLangs = navigator.languages?.length
@@ -55,17 +53,14 @@ export function detectBrowserLanguage(): string | null {
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
for (const lang of browserLangs) {
// Exact match (e.g. 'de', 'zh-TW') — case-insensitive
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
if (exactMatch) return exactMatch
// pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly.
// pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the
// server default or 'en' fallback apply instead.
// pt-BR has no exact match (our code is 'br'), so map it explicitly.
// pt-PT and bare 'pt' are NOT mapped — they fall through to null.
if (lang.toLowerCase() === 'pt-br') return 'br'
// Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive
const prefix = lang.split('-')[0].toLowerCase()
const prefix = lang.split('-')[0]?.toLowerCase()
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
if (prefixMatch) return prefixMatch
}
@@ -75,11 +70,32 @@ export function detectBrowserLanguage(): string | null {
interface TranslationContextValue {
t: (key: string, params?: Record<string, string | number>) => string
/**
* HTML-aware variant of `t()`. Use ONLY when the translated template
* legitimately contains markup (e.g. `'Turn <strong>{title}</strong> into a Journey'`).
*
* Defence in depth, two layers:
* 1. Every interpolated param is HTML-escaped before substitution, so a
* user-controlled value like `<script>` cannot inject markup at all.
* 2. The fully-substituted string is then passed through
* `sanitizeInlineHtml`, so even if a translator ships a malformed
* template the runtime output is still tag-restricted.
*
* Prefer the `<TransHtml>` component for the typical "translate + render"
* pattern; reach for `tHtml()` directly only when you need the raw string
* (e.g. constructing an `aria-label`).
*/
tHtml: (key: string, params?: Record<string, string | number>) => string
language: string
locale: string
}
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'en', locale: 'en-US' })
const TranslationContext = createContext<TranslationContextValue>({
t: (k: string) => k,
tHtml: (k: string) => k,
language: 'en',
locale: 'en-US',
})
interface TranslationProviderProps {
children: ReactNode
@@ -87,18 +103,27 @@ interface TranslationProviderProps {
export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'en'
const [strings, setStrings] = useState<TranslationStrings>(en)
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
}, [language])
const value = useMemo((): TranslationContextValue => {
const strings = translations[language] || translations.en
const fallback = translations.en
useEffect(() => {
const loader = localeLoaders[language as SupportedLanguageCode]
if (!loader) return
let cancelled = false
loader().then(mod => {
if (!cancelled) setStrings(mod.default)
})
return () => { cancelled = true }
}, [language])
const value = useMemo((): TranslationContextValue => {
function t(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? fallback[key] ?? key) as string
let val: string = (strings[key] ?? en[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
@@ -107,8 +132,23 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
return val
}
return { t, language, locale: getLocaleForLanguage(language) }
}, [language])
function tHtml(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? en[key] ?? key) as string
if (params) {
Object.entries(params).forEach(([k, v]) => {
// Escape BEFORE substitution so a user-controlled value with `<` or
// `&` cannot break out of the surrounding template's markup.
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), escapeHtml(String(v)))
})
}
// Then re-sanitise the fully-built string: even if a translator ships a
// template with stray `<script>` or `onclick`, the rendered output is
// restricted to the inline tag allow-list.
return sanitizeInlineHtml(val)
}
return { t, tHtml, language, locale: getLocaleForLanguage(language) }
}, [strings, language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
+1
View File
@@ -7,3 +7,4 @@ export {
detectBrowserLanguage,
SUPPORTED_LANGUAGES,
} from './TranslationContext'
export { TransHtml } from './TransHtml'
+4 -21
View File
@@ -1,21 +1,4 @@
export const SUPPORTED_LANGUAGES = [
{ value: 'de', label: 'Deutsch', locale: 'de-DE' },
{ value: 'en', label: 'English', locale: 'en-US' },
{ value: 'es', label: 'Español', locale: 'es-ES' },
{ value: 'fr', label: 'Français', locale: 'fr-FR' },
{ value: 'hu', label: 'Magyar', locale: 'hu-HU' },
{ value: 'nl', label: 'Nederlands', locale: 'nl-NL' },
{ value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' },
{ value: 'cs', label: 'Česky', locale: 'cs-CZ' },
{ value: 'pl', label: 'Polski', locale: 'pl-PL' },
{ value: 'ru', label: 'Русский', locale: 'ru-RU' },
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value)
// Canonical language registry now lives in @trek/shared. Re-exported here so
// existing imports of './supportedLanguages' continue to work unchanged.
export { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES } from '@trek/shared'
export type { SupportedLanguageCode } from '@trek/shared'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -489,7 +489,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
}
@media (min-width: 768px) {
:root { --nav-h: calc(56px + env(safe-area-inset-top, 0px)); }
:root { --nav-h: calc(64px + env(safe-area-inset-top, 0px)); }
}
.dark {
@@ -812,3 +812,21 @@ img[alt="TREK"] {
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
/* Day-plan header action grid (edit / +transport / note / collapse) */
.dp-day-actions button {
color: var(--text-faint);
background: transparent;
transition: background-color 0.12s ease, color 0.12s ease;
}
.dp-day-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Reveal the action grid only when hovering the day row (pointer devices).
Touch devices (hover: none) keep it visible; the selected day stays visible too. */
@media (hover: hover) {
.dp-day-actions { opacity: 0; transition: opacity 0.12s ease; }
.dp-day-header:hover .dp-day-actions,
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
}
+35 -346
View File
@@ -8,7 +8,7 @@ import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
import { useTranslation } from '../i18n'
import { getApiErrorMessage } from '../types'
import Navbar from '../components/Layout/Navbar'
import PageShell from '../components/Layout/PageShell'
import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp'
@@ -23,43 +23,7 @@ import PermissionsPanel from '../components/Admin/PermissionsPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
interface AdminUser {
id: number
username: string
email: string
role: 'admin' | 'user'
created_at: string
last_login?: string | null
online?: boolean
oidc_issuer?: string | null
avatar_url?: string | null
}
interface AdminStats {
totalUsers: number
totalTrips: number
totalPlaces: number
totalFiles: number
}
interface OidcConfig {
issuer: string
client_id: string
client_secret: string
client_secret_set: boolean
display_name: string
discovery_url: string
}
interface UpdateInfo {
update_available: boolean
latest: string
current: string
release_url?: string
is_docker?: boolean
is_prerelease?: boolean
}
import { useAdmin } from './admin/useAdmin'
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
version_available: 'settings.notifyVersionAvailable',
@@ -179,11 +143,38 @@ function AdminStatCard({ label, value, icon: Icon }: { label: string; value: num
}
export default function AdminPage(): React.ReactElement {
const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
const devMode = useAuthStore(s => s.devMode)
// Page = wiring container: all admin data slices + handlers live in the hook.
const {
demoMode, serverTimezone, hour12, mcpEnabled, devMode, currentUser,
updateApiKeys, setAppRequireMfa, setTripRemindersEnabled,
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout,
navigate, toast,
activeTab, setActiveTab, users, setUsers, stats, isLoading,
editingUser, setEditingUser, editForm, setEditForm,
showCreateUser, setShowCreateUser, createForm, setCreateForm,
bagTrackingEnabled, setBagTrackingEnabled,
placesPhotosEnabled, setPlacesPhotosEnabledState,
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
collabFeatures, setCollabFeatures,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, setEnvOverrideOidcOnly, oidcConfigured, setOidcConfigured,
requireMfa, setRequireMfa,
invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
loadData, loadAppConfig, loadApiKeys, handleToggleAuthSetting, handleToggleRequireMfa,
toggleKey, handleSaveApiKeys, handleValidateKeys, handleValidateKey,
handleCreateUser, handleCreateInvite, handleDeleteInvite, copyInviteLink,
handleEditUser, handleSaveUser, handleDeleteUser,
} = useAdmin()
const TABS: PageSidebarTab[] = [
{ id: 'users', label: t('admin.tabs.users'), icon: Users },
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
@@ -198,309 +189,8 @@ export default function AdminPage(): React.ReactElement {
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
]
const [activeTab, setActiveTab] = useState<string>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(true)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
// Bag tracking
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// Places photos
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
// Places autocomplete
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
// Places details
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
// Collab features
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
// OIDC config
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Auth toggles
const [passwordLogin, setPasswordLogin] = useState<boolean>(true)
const [passwordRegistration, setPasswordRegistration] = useState<boolean>(true)
const [oidcLogin, setOidcLogin] = useState<boolean>(true)
const [oidcRegistration, setOidcRegistration] = useState<boolean>(true)
const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState<boolean>(false)
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Invite links
const [invites, setInvites] = useState<any[]>([])
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(false)
const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 })
// File types
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
// SMTP settings
const [smtpValues, setSmtpValues] = useState<Record<string, string>>({})
const [smtpLoaded, setSmtpLoaded] = useState(false)
useEffect(() => {
apiClient.get('/auth/app-settings').then(r => {
setSmtpValues(r.data || {})
setSmtpLoaded(true)
}).catch(() => setSmtpLoaded(true))
}, [])
// API Keys
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
const [validation, setValidation] = useState<Record<string, boolean | undefined>>({})
// Version check & update
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
const [showRotateJwtModal, setShowRotateJwtModal] = useState<boolean>(false)
const [rotatingJwt, setRotatingJwt] = useState<boolean>(false)
useEffect(() => {
loadData()
loadAppConfig()
loadApiKeys()
adminApi.getOidc().then(setOidcConfig).catch(() => {})
adminApi.checkVersion().then(data => {
if (data.update_available) setUpdateInfo(data)
}).catch(() => {})
}, [])
const loadData = async () => {
setIsLoading(true)
try {
const [usersData, statsData, invitesData] = await Promise.all([
adminApi.users(),
adminApi.stats(),
adminApi.listInvites().catch(() => ({ invites: [] })),
])
setUsers(usersData.users)
setStats(statsData)
setInvites(invitesData.invites || [])
} catch (err: unknown) {
toast.error(t('admin.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAppConfig = async () => {
try {
const config = await authApi.getAppConfig()
setPasswordLogin(config.password_login ?? true)
setPasswordRegistration(config.password_registration ?? config.allow_registration ?? true)
setOidcLogin(config.oidc_login ?? true)
setOidcRegistration(config.oidc_registration ?? config.allow_registration ?? true)
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
setOidcConfigured(config.oidc_configured ?? false)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
// ignore
}
}
const loadApiKeys = async () => {
try {
const data = await authApi.getSettings()
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
} catch (err: unknown) {
// ignore
}
}
const handleToggleAuthSetting = async (key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
try {
await authApi.updateAppSettings({ [key]: value })
} catch (err: unknown) {
setter(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const handleToggleRequireMfa = async (value: boolean) => {
setRequireMfa(value)
try {
await authApi.updateAppSettings({ require_mfa: value })
setAppRequireMfa(value)
toast.success(t('common.saved'))
} catch (err: unknown) {
setRequireMfa(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
const handleSaveApiKeys = async () => {
setSavingKeys(true)
try {
await updateApiKeys({
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
})
toast.success(t('admin.keySaved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
} finally {
setSavingKeys(false)
}
}
const handleValidateKeys = async () => {
setValidating({ maps: true, weather: true })
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating({})
}
}
const handleValidateKey = async (keyType) => {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) {
toast.error(t('common.error'))
} finally {
setValidating(prev => ({ ...prev, [keyType]: false }))
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
return
}
if (createForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.createError')))
}
}
const handleCreateInvite = async () => {
try {
const data = await adminApi.createInvite({
max_uses: inviteForm.max_uses,
expires_in_days: inviteForm.expires_in_days || undefined,
})
setInvites(prev => [data.invite, ...prev])
setShowCreateInvite(false)
setInviteForm({ max_uses: 1, expires_in_days: 7 })
// Copy link to clipboard
const link = `${window.location.origin}/register?invite=${data.invite.token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.invite.createError')))
}
}
const handleDeleteInvite = async (id: number) => {
try {
await adminApi.deleteInvite(id)
setInvites(prev => prev.filter(i => i.id !== id))
toast.success(t('admin.invite.deleted'))
} catch {
toast.error(t('admin.invite.deleteError'))
}
}
const copyInviteLink = (token: string) => {
const link = `${window.location.origin}/register?invite=${token}`
navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied')))
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
}
const handleSaveUser = async () => {
try {
const payload: { username?: string; email?: string; role: string; password?: string } = {
username: editForm.username.trim() || undefined,
email: editForm.email.trim() || undefined,
role: editForm.role,
}
if (editForm.password.trim()) {
if (editForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
payload.password = editForm.password.trim()
}
const data = await adminApi.updateUser(editingUser.id, payload)
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
toast.success(t('admin.toast.userUpdated'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.updateError')))
}
}
const handleDeleteUser = async (user) => {
if (user.id === currentUser?.id) {
toast.error(t('admin.toast.cannotDeleteSelf'))
return
}
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
try {
await adminApi.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
toast.success(t('admin.toast.userDeleted'))
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('admin.toast.deleteError')))
}
}
return (
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<PageShell background="var(--bg-secondary)">
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
@@ -1612,7 +1302,6 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</PageSidebar>
</div>
</div>
{/* Create user modal */}
<Modal
@@ -1876,6 +1565,6 @@ docker run -d --name trek \\
</div>
</div>
</Modal>
</div>
</PageShell>
)
}
+28 -709
View File
@@ -1,48 +1,12 @@
import React, { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n'
import { useSettingsStore } from '../store/settingsStore'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import apiClient, { mapsApi } from '../api/client'
import apiClient from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import L from 'leaflet'
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
// Convert country code to flag emoji
interface AtlasCountry {
code: string
tripCount: number
placeCount: number
firstVisit?: string | null
lastVisit?: string | null
}
interface AtlasStats {
totalTrips: number
totalPlaces: number
totalCountries: number
totalDays: number
totalCities?: number
}
interface AtlasData {
countries: AtlasCountry[]
stats: AtlasStats
mostVisited?: AtlasCountry | null
continents?: Record<string, number>
lastTrip?: { id: number; title: string; countryCode?: string } | null
nextTrip?: { id: number; title: string; countryCode?: string } | null
streak?: number
firstYear?: number
tripsThisYear?: number
}
interface CountryDetail {
places: AtlasPlace[]
trips: { id: number; title: string }[]
manually_marked?: boolean
}
import type { TranslationFn } from '../types'
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { useAtlas } from './atlas/useAtlas'
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
const tp = dark ? '#f1f5f9' : '#0f172a'
@@ -93,674 +57,29 @@ function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: A
)
}
function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
}
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
try {
const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
} catch { /* */ }
}, [language])
return resolver
}
// ISO-3166-1 alpha-2 → alpha-3 mapping. Two sources feed this table:
// 1. Hardcoded entries below — REQUIRED for any country whose Natural Earth GeoJSON record
// has ISO_A2='-99' (e.g. France=FRA, Norway=NOR). The runtime augmentation loop
// (see geoData useEffect below) skips '-99' features, so those countries MUST be
// listed here or the atlas_country_options A3-fallback will silently fail.
// 2. Runtime augmentation — the geoData load effect adds entries for every feature
// that has a valid ISO_A2, covering territories not present below.
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
export default function AtlasPage(): React.ReactElement {
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const navigate = useNavigate()
const resolveName = useCountryNames(language)
const dm = settings.dark_mode
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const mapRef = useRef<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
const rect = panelRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Subtle inner glow
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
glareRef.current.style.opacity = '1'
// Border glow that follows cursor
borderGlareRef.current.style.opacity = '1'
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
}
const handlePanelMouseLeave = () => {
if (glareRef.current) glareRef.current.style.opacity = '0'
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
}
const [data, setData] = useState<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
const regionLayerRef = useRef<L.GeoJSON | null>(null)
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
const [showRegions, setShowRegions] = useState(false)
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
const regionTooltipRef = useRef<HTMLDivElement>(null)
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null)
const [bucketMonth, setBucketMonth] = useState(0)
const [bucketYear, setBucketYear] = useState(0)
// Bucket list
interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null }
const [bucketList, setBucketList] = useState<BucketItem[]>([])
const [showBucketAdd, setShowBucketAdd] = useState(false)
const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
const [bucketSearch, setBucketSearch] = useState('')
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
const [bucketSearching, setBucketSearching] = useState(false)
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
const [bucketPoiYear, setBucketPoiYear] = useState(0)
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
const bucketMarkersRef = useRef<any>(null)
const [atlas_country_search, set_atlas_country_search] = useState('')
const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([])
const [atlas_country_open, set_atlas_country_open] = useState(false)
const atlas_country_options = useMemo(() => {
if (!geoData) return []
// Precompute A3 → A2 reverse lookup once per geoData change instead of
// scanning A2_TO_A3 for every feature that needs the fallback.
const a3ToA2 = new Map<string, string>()
for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key)
const opts: { code: string; label: string }[] = []
const seen = new Set<string>()
for (const f of (geoData as any).features || []) {
const rawA2 = f?.properties?.ISO_A2
let resolvedA2: string | null = (typeof rawA2 === 'string' && rawA2.length === 2 && rawA2 !== '-99') ? rawA2 : null
if (!resolvedA2) {
const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null
if (a3 && a3 !== '-99') resolvedA2 = a3ToA2.get(a3) ?? null
}
if (!resolvedA2 || seen.has(resolvedA2)) continue
seen.add(resolvedA2)
const label = String(resolveName(resolvedA2) || f?.properties?.NAME || f?.properties?.ADMIN || resolvedA2)
opts.push({ code: resolvedA2, label })
}
opts.sort((a, b) => a.label.localeCompare(b.label))
return opts
}, [geoData, resolveName])
// Load atlas data + bucket list
useEffect(() => {
Promise.all([
apiClient.get('/addons/atlas/stats'),
apiClient.get('/addons/atlas/bucket-list'),
]).then(([statsRes, bucketRes]) => {
setData(statsRes.data)
setBucketList(bucketRes.data.items || [])
setLoading(false)
}).catch(() => setLoading(false))
}, [])
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => {
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON
for (const f of geo.features) {
const a2 = f.properties?.ISO_A2
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
A2_TO_A3[a2] = a3
}
}
setGeoData(geo)
})
.catch(() => {})
}, [])
// Load visited regions (geocoded from places/trips) — once on mount
useEffect(() => {
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
.then(r => setVisitedRegions(r.data?.regions || {}))
.catch(() => {})
}, [])
// Load admin-1 GeoJSON for countries visible in the current viewport
const loadRegionsForViewportRef = useRef<() => void>(() => {})
const loadRegionsForViewport = (): void => {
if (!mapInstance.current) return
const bounds = mapInstance.current.getBounds()
const toLoad: string[] = []
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
if (regionGeoCache.current[code]) continue
try {
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
} catch {}
}
if (!toLoad.length) return
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
.then(geoRes => {
const geo = geoRes.data
if (!geo?.features) return
let added = false
for (const c of toLoad) {
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
}
if (added) setRegionGeoLoaded(v => v + 1)
})
.catch(() => {})
}
loadRegionsForViewportRef.current = loadRegionsForViewport
// Initialize map — runs after loading is done and mapRef is available
useEffect(() => {
if (loading || !mapRef.current) return
if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null }
const map = L.map(mapRef.current, {
center: [25, 0],
zoom: 3,
minZoom: 3,
maxZoom: 10,
zoomControl: false,
attributionControl: false,
maxBounds: [[-90, -220], [90, 220]],
maxBoundsViscosity: 1.0,
fadeAnimation: false,
preferCanvas: true,
})
L.control.zoom({ position: 'bottomright' }).addTo(map)
const tileUrl = dark
? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
L.tileLayer(tileUrl, {
maxZoom: 10,
keepBuffer: 25,
updateWhenZooming: true,
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
// Preload adjacent zoom level tiles
L.tileLayer(tileUrl, {
maxZoom: 10,
keepBuffer: 10,
opacity: 0,
tileSize: 256,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
}).addTo(map)
// Custom pane for region layer — above overlay (z-index 400)
map.createPane('regionPane')
map.getPane('regionPane')!.style.zIndex = '401'
mapInstance.current = map
// Zoom-based region switching
map.on('zoomend', () => {
const z = map.getZoom()
const shouldShow = z >= 5
setShowRegions(shouldShow)
const overlayPane = map.getPane('overlayPane')
if (overlayPane) {
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
}
if (shouldShow) {
// Re-add region layer if it was removed while zoomed out
if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.addTo(map)
}
loadRegionsForViewportRef.current()
} else {
// Physically remove region layer so its SVG paths can't intercept events
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) {
regionLayerRef.current.resetStyle()
regionLayerRef.current.removeFrom(map)
}
}
})
map.on('moveend', () => {
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
})
return () => { map.remove(); mapInstance.current = null }
}, [dark, loading])
// Render GeoJSON countries
useEffect(() => {
if (!mapInstance.current || !geoData || !data) return
const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean))
const countryMap = {}
data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
// Preserve current map view
const currentCenter = mapInstance.current.getCenter()
const currentZoom = mapInstance.current.getZoom()
if (geoLayerRef.current) {
mapInstance.current.removeLayer(geoLayerRef.current)
}
// Generate deterministic color per country code
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
// Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily
const visitedA3List = [...visitedA3]
const colorMap = {}
visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0]
const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
geoLayerRef.current = L.geoJSON(geoData, {
renderer: canvasRenderer,
interactive: true,
bubblingMouseEvents: false,
style: (feature) => {
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const visited = visitedA3.has(a3)
return {
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
fillOpacity: visited ? 0.7 : 0.3,
color: dark ? '#333' : '#cbd5e1',
weight: 0.5,
}
},
onEachFeature: (feature, layer) => {
const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id
const c = countryMap[a3]
if (c) {
country_layer_by_a2_ref.current[c.code] = layer
const name = resolveName(c.code)
const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) }
const tooltipHtml = `
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
<div style="display:flex;gap:14px">
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}</span></div>
</div>
<div style="display:flex;gap:2px;border-top:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};padding-top:8px">
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.firstVisit')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.firstVisit)}</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.lastVisitLabel')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.lastVisit)}</span>
</div>
</div>
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
handleUnmarkCountry(c.code)
}
})
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
} else {
// Unvisited country — allow clicking to mark as visited
// Reverse lookup: find A2 code from A3, or use A3 directly
const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3)
const isoA2 = feature.properties?.ISO_A2
const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null)
if (countryCode && countryCode !== '-99') {
country_layer_by_a2_ref.current[countryCode] = layer
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => handleMarkCountry(countryCode, name))
layer.on('mouseover', (e) => {
e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' })
})
layer.on('mouseout', (e) => {
geoLayerRef.current.resetStyle(e.target)
})
}
}
}
}).addTo(mapInstance.current)
// Restore map view after re-render
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
}, [geoData, data, dark])
// Render sub-national region layer (zoom >= 5)
useEffect(() => {
if (!mapInstance.current) return
// Remove existing region layer
if (regionLayerRef.current) {
mapInstance.current.removeLayer(regionLayerRef.current)
regionLayerRef.current = null
}
if (Object.keys(regionGeoCache.current).length === 0) return
// Build set of visited region codes and per-country name sets
const visitedRegionCodes = new Set<string>()
const visitedRegionNamesByCountry = new Map<string, Set<string>>()
const regionPlaceCounts: Record<string, number> = {}
for (const [countryCode, regions] of Object.entries(visitedRegions)) {
const names = new Set<string>()
for (const r of regions) {
visitedRegionCodes.add(r.code)
names.add(r.name.toLowerCase())
regionPlaceCounts[r.code] = r.placeCount
regionPlaceCounts[`${countryCode}:${r.name.toLowerCase()}`] = r.placeCount
}
visitedRegionNamesByCountry.set(countryCode, names)
}
// Match feature by ISO code OR region name scoped to the feature's country
const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const countryA2 = (f.properties?.iso_a2 || '').toUpperCase()
const countryNames = visitedRegionNamesByCountry.get(countryA2)
if (!countryNames) return false
const name = (f.properties?.name || '').toLowerCase()
if (countryNames.has(name)) return true
const nameEn = (f.properties?.name_en || '').toLowerCase()
if (nameEn && countryNames.has(nameEn)) return true
return false
}
// Include ALL region features — visited ones get colored fill, unvisited get outline only
const allFeatures: any[] = []
for (const geo of Object.values(regionGeoCache.current)) {
for (const f of geo.features) {
allFeatures.push(f)
}
}
if (allFeatures.length === 0) return
// Use same colors as country layer
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
const countryColorMap: Record<string, string> = {}
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
// Map country A2 code to country color
const a2ColorMap: Record<string, string> = {}
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
const svgRenderer = L.svg({ pane: 'regionPane' })
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
renderer: svgRenderer,
interactive: true,
pane: 'regionPane',
style: (feature) => {
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
return visited ? {
fillColor: a2ColorMap[countryA2] || '#6366f1',
fillOpacity: 0.85,
color: dark ? '#888' : '#64748b',
weight: 1.2,
} : {
fillColor: dark ? '#ffffff' : '#000000',
fillOpacity: 0.03,
color: dark ? '#555' : '#94a3b8',
weight: 1,
}
},
onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || ''
const regionNameEn = feature?.properties?.name_en || ''
const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[`${countryA2}:${regionName.toLowerCase()}`] || regionPlaceCounts[`${countryA2}:${regionNameEn.toLowerCase()}`] || 0
layer.on('click', () => {
if (!countryA2) return
if (visited) {
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
if (regionEntry?.manuallyMarked) {
setConfirmActionRef.current({
type: 'unmark-region',
code: countryA2,
name: regionName,
regionCode,
countryName,
})
} else {
loadCountryDetailRef.current(countryA2)
}
} else {
setConfirmActionRef.current({
type: 'choose-region',
code: countryA2, // country A2 code — used for flag display
name: regionName, // region name — shown as heading
regionCode,
countryName,
})
}
})
layer.on('mouseover', (e: any) => {
e.target.setStyle(visited
? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }
: { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' }
)
const tt = regionTooltipRef.current
if (tt) {
tt.style.display = 'block'
tt.style.left = e.originalEvent.clientX + 12 + 'px'
tt.style.top = e.originalEvent.clientY - 10 + 'px'
tt.innerHTML = visited
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
}
})
layer.on('mousemove', (e: any) => {
const tt = regionTooltipRef.current
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
})
layer.on('mouseout', (e: any) => {
regionLayerRef.current?.resetStyle(e.target)
const tt = regionTooltipRef.current
if (tt) tt.style.display = 'none'
})
},
})
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
if (mapInstance.current.getZoom() >= 6) {
regionLayerRef.current.addTo(mapInstance.current)
}
}, [regionGeoLoaded, visitedRegions, dark, t])
const handleMarkCountry = (code: string, name: string): void => {
setConfirmAction({ type: 'choose', code, name })
}
handleMarkCountryRef.current = handleMarkCountry
setConfirmActionRef.current = setConfirmAction
const handleUnmarkCountry = (code: string): void => {
const country = data?.countries.find(c => c.code === code)
setConfirmAction({ type: 'unmark', code, name: resolveName(code) })
}
const select_country_from_search = (country_code: string): void => {
const country_label = resolveName(country_code)
set_atlas_country_search(country_label)
set_atlas_country_open(false)
set_atlas_country_results([])
const layer = country_layer_by_a2_ref.current[country_code]
try {
if (layer?.getBounds && mapInstance.current) {
mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 })
}
} catch (e ) {
console.error('Error fitting bounds', e)
}
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
}
const executeConfirmAction = async (): Promise<void> => {
if (!confirmAction) return
const { type, code } = confirmAction
setConfirmAction(null)
// Update local state immediately (no API reload = no map re-render flash)
if (type === 'mark') {
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === code)) return prev
return {
...prev,
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
}
})
} else {
apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {})
setSelectedCountry(null)
setCountryDetail(null)
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === code)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
return {
...prev,
countries: prev.countries.filter(c => c.code !== code),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
setVisitedRegions(prev => {
if (!prev[code]) return prev
const next = { ...prev }
delete next[code]
return next
})
}
}
const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return
try {
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
if (targetDate) data.target_date = targetDate
const r = await apiClient.post('/addons/atlas/bucket-list', data)
setBucketList(prev => [r.data.item, ...prev])
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
setShowBucketAdd(false)
} catch { /* */ }
}
const handleDeleteBucketItem = async (id: number): Promise<void> => {
try {
await apiClient.delete(`/addons/atlas/bucket-list/${id}`)
setBucketList(prev => prev.filter(i => i.id !== id))
} catch { /* */ }
}
const handleBucketPoiSearch = async () => {
if (!bucketSearch.trim()) return
setBucketSearching(true)
try {
const result = await mapsApi.search(bucketSearch, language)
setBucketSearchResults(result.places || [])
} catch {} finally { setBucketSearching(false) }
}
const handleSelectBucketPoi = (result: any) => {
const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null
setBucketForm({
name: result.name || bucketSearch,
notes: '',
lat: String(result.lat || ''),
lng: String(result.lng || ''),
target_date: targetDate || '',
})
setBucketSearchResults([])
setBucketSearch('')
}
// Render bucket list markers on map
useEffect(() => {
if (!mapInstance.current) return
if (bucketMarkersRef.current) {
mapInstance.current.removeLayer(bucketMarkersRef.current)
}
if (bucketList.length === 0) return
const markers = bucketList.filter(b => b.lat && b.lng).map(b => {
const icon = L.divIcon({
className: '',
html: `<div style="width:28px;height:28px;border-radius:50%;background:rgba(251,191,36,0.9);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
})
return L.marker([b.lat!, b.lng!], { icon }).bindTooltip(
`<div style="font-size:12px;font-weight:600">${b.name}</div>${b.notes ? `<div style="font-size:10px;opacity:0.7;margin-top:2px">${b.notes}</div>` : ''}`,
{ className: 'atlas-tooltip', direction: 'top', offset: [0, -14] }
)
})
bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current)
}, [bucketList])
const loadCountryDetail = async (code: string): Promise<void> => {
setSelectedCountry(code)
try {
const r = await apiClient.get(`/addons/atlas/country/${code}`)
setCountryDetail(r.data)
} catch { /* */ }
}
loadCountryDetailRef.current = loadCountryDetail
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
const countries = data?.countries || []
// Page = wiring container: the whole interactive globe (map lifecycle, atlas +
// bucket data, mark/unmark flows, country search) lives in useAtlas. The page
// only wires that state into JSX and its presentational SidebarContent helper.
const {
t, language, navigate, resolveName, dark, loading,
mapRef, regionTooltipRef, panelRef, glareRef, borderGlareRef,
handlePanelMouseMove, handlePanelMouseLeave,
data, setData, stats, countries, selectedCountry, countryDetail,
loadCountryDetail, handleUnmarkCountry, select_country_from_search,
visitedRegions, setVisitedRegions,
atlas_country_search, set_atlas_country_search,
atlas_country_results, set_atlas_country_results,
atlas_country_open, set_atlas_country_open, atlas_country_options,
confirmAction, setConfirmAction, executeConfirmAction,
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
bucketList, setBucketList, bucketTab, setBucketTab,
showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm,
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
bucketSearching, bucketSearch, setBucketSearch,
} = useAtlas()
if (loading) {
return (
+67 -114
View File
@@ -65,7 +65,7 @@ describe('DashboardPage', () => {
});
describe('FE-PAGE-DASH-004: Empty state when no trips', () => {
it('shows empty state message when API returns no trips', async () => {
it('shows the add-trip card when API returns no trips', async () => {
server.use(
http.get('/api/trips', () => {
return HttpResponse.json({ trips: [] });
@@ -74,8 +74,9 @@ describe('DashboardPage', () => {
render(<DashboardPage />);
// With no trips the planned filter falls back to the "add trip" card
await waitFor(() => {
expect(screen.getByText(/no trips yet/i)).toBeInTheDocument();
expect(screen.getByText(/plan a new trip from scratch/i)).toBeInTheDocument();
});
});
});
@@ -206,17 +207,11 @@ describe('DashboardPage', () => {
});
});
describe('FE-PAGE-DASH-011: Archive trip moves it to archived section', () => {
it('archiving a trip removes it from active and shows it in archived section', async () => {
describe('FE-PAGE-DASH-011: Archive trip moves it to the archive filter', () => {
it('archiving a trip removes it from active and shows it under the archive filter', async () => {
const archivedTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10', is_archived: true });
server.use(
http.put('/api/trips/:id', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
if (body.is_archived === true) {
return HttpResponse.json({ trip: archivedTrip });
}
return HttpResponse.json({ trip: archivedTrip });
}),
http.put('/api/trips/:id', () => HttpResponse.json({ trip: archivedTrip })),
);
const user = userEvent.setup();
@@ -226,17 +221,12 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
});
// Click archive button
const archiveButtons = screen.getAllByRole('button', { name: /archive/i });
// The spotlight hero exposes an icon-only archive action
const archiveButtons = screen.getAllByRole('button', { name: /archive/i }).filter(b => !b.textContent?.trim());
await user.click(archiveButtons[0]);
// Wait for archived section toggle to appear
await waitFor(() => {
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
});
// Click "Archived" toggle to show archived trips
await user.click(screen.getByRole('button', { name: /archived/i }));
// Switch to the archive filter segment
await user.click(screen.getByText('Archive'));
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
@@ -272,8 +262,8 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
});
// Find the view mode toggle button (shows List icon when in grid mode, title "List view")
const viewToggle = screen.getByTitle(/list view/i);
// The view-mode toggle flips grid ↔ list and persists the choice
const viewToggle = screen.getByRole('button', { name: /toggle view/i });
await user.click(viewToggle);
// localStorage should be updated to 'list'
@@ -281,8 +271,8 @@ describe('DashboardPage', () => {
});
});
describe('FE-PAGE-DASH-014: Archived trips section toggles visibility', () => {
it('shows archived trips when the archived section toggle is clicked', async () => {
describe('FE-PAGE-DASH-014: Archive filter reveals archived trips', () => {
it('shows archived trips when the archive filter is selected', async () => {
const oldTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: true });
server.use(
http.get('/api/trips', ({ request }) => {
@@ -302,13 +292,8 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
});
// Archived section toggle should be present
await waitFor(() => {
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
});
// Click to expand
await user.click(screen.getByRole('button', { name: /archived/i }));
// Switch to the archive filter
await user.click(screen.getByText('Archive'));
await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
@@ -343,7 +328,7 @@ describe('DashboardPage', () => {
});
// Switch to list view
const viewToggle = screen.getByTitle(/list view/i);
const viewToggle = screen.getByRole('button', { name: /toggle view/i });
await user.click(viewToggle);
// Non-spotlight trips should be visible in list view
@@ -367,7 +352,7 @@ describe('DashboardPage', () => {
});
// Switch to list view
const viewToggle = screen.getByTitle(/list view/i);
const viewToggle = screen.getByRole('button', { name: /toggle view/i });
await user.click(viewToggle);
// Non-spotlight trips render in list view
@@ -397,8 +382,8 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
});
// Find copy buttons
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
// Find duplicate buttons (the copy action is labelled "Duplicate")
const copyButtons = screen.getAllByRole('button', { name: /duplicate/i });
await user.click(copyButtons[0]);
// Confirm the copy dialog
@@ -411,28 +396,18 @@ describe('DashboardPage', () => {
});
});
describe('FE-PAGE-DASH-019: Widget settings dropdown opens and closes', () => {
it('clicking the settings button shows the widget toggles', async () => {
const user = userEvent.setup();
describe('FE-PAGE-DASH-019: Currency converter widget renders in the sidebar', () => {
it('shows the currency widget with from/to fields', async () => {
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
});
// Find settings button — the gear icon button (icon-only, no visible label)
const allBtns = screen.getAllByRole('button');
const settingsButton = allBtns.find(btn =>
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
);
expect(settingsButton).toBeDefined();
if (settingsButton) {
await user.click(settingsButton);
await waitFor(() => {
expect(screen.getByText('Widgets:')).toBeInTheDocument();
});
}
// The sidebar currency tool exposes its title and the From/To converter fields
expect(screen.getByText('Currency')).toBeInTheDocument();
expect(screen.getByText('From')).toBeInTheDocument();
expect(screen.getByText('To')).toBeInTheDocument();
});
});
@@ -463,23 +438,23 @@ describe('DashboardPage', () => {
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
});
// Expand archived section
await user.click(screen.getByRole('button', { name: /archived/i }));
// Switch to the archive filter
await user.click(screen.getByText('Archive'));
await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
});
// Click restore button
const restoreBtn = screen.getByRole('button', { name: /restore/i });
await user.click(restoreBtn);
// An archived card's archive action is labelled "Restore" and toggles the trip back to active
const card = screen.getByText('Old Rome Trip').closest('.trip-card') as HTMLElement;
await user.click(card.querySelector('[aria-label="Restore"]') as HTMLElement);
// After restore, archived section should disappear (no more archived trips)
// Once restored there are no archived trips left to show
await waitFor(() => {
expect(screen.queryByRole('button', { name: /archived/i })).not.toBeInTheDocument();
expect(screen.queryByText('Old Rome Trip')).not.toBeInTheDocument();
});
});
});
@@ -572,15 +547,12 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Current Voyage').length).toBeGreaterThan(0);
});
// Live badge text appears (mobile + desktop spotlight)
// Live badge appears on the boarding-pass hero for an ongoing trip
await waitFor(() => {
expect(screen.getAllByText(/live now/i).length).toBeGreaterThan(0);
});
// Progress bar label "Trip progress" appears
expect(screen.getAllByText(/trip progress/i).length).toBeGreaterThan(0);
// "days left" label appears inside the progress section
// The countdown ring labels the remaining days
expect(screen.getAllByText(/days left/i).length).toBeGreaterThan(0);
});
});
@@ -612,11 +584,10 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Upcoming Safari').length).toBeGreaterThan(0);
});
// Badge should show "X days left" countdown (not "Live now")
// An upcoming trip is not "live", and the countdown cell counts down to the start
expect(screen.queryByText(/live now/i)).not.toBeInTheDocument();
// The SpotlightCard renders a badge with the countdown text containing "days"
await waitFor(() => {
expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/trip starts in/i).length).toBeGreaterThan(0);
});
});
});
@@ -636,39 +607,22 @@ describe('DashboardPage', () => {
});
});
describe('FE-PAGE-DASH-026: Widget settings toggles currency and timezone', () => {
it('toggling currency widget off hides it from settings', async () => {
const user = userEvent.setup();
describe('FE-PAGE-DASH-026: Timezone widget renders in the sidebar', () => {
it('shows the timezone widget with an add-zone control', async () => {
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
});
// Open widget settings — gear icon button (icon-only, no visible label)
const allBtns = screen.getAllByRole('button');
const settingsButton = allBtns.find(btn =>
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
);
expect(settingsButton).toBeDefined();
if (settingsButton) {
await user.click(settingsButton);
await waitFor(() => {
expect(screen.getByText('Widgets:')).toBeInTheDocument();
});
// Both currency and timezone toggle labels should be visible
// Use getAllByText because labels may appear in both widget settings and quick actions
expect(screen.getAllByText(/currency/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/timezone/i).length).toBeGreaterThan(0);
}
// The timezone tool title and its add-zone button are present
expect(screen.getByText('Timezones')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument();
});
});
describe('FE-PAGE-DASH-027: Archived section expand and collapse', () => {
it('expands and then collapses the archived trips section', async () => {
describe('FE-PAGE-DASH-027: Archive filter toggles archived trips in and out of view', () => {
it('shows archived trips under the archive filter and hides them under planned', async () => {
const activeTrip = buildTrip({ title: 'Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' });
const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: true });
@@ -686,17 +640,17 @@ describe('DashboardPage', () => {
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
expect(screen.getAllByText('Active Trip')[0]).toBeInTheDocument();
});
// Expand
await user.click(screen.getByRole('button', { name: /archived/i }));
// Archive filter reveals the archived trip
await user.click(screen.getByText('Archive'));
await waitFor(() => {
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
});
// Collapse
await user.click(screen.getByRole('button', { name: /archived/i }));
// Switching back to the planned filter hides it again
await user.click(screen.getByText('Planned'));
await waitFor(() => {
expect(screen.queryByText('Old Archived Trip')).not.toBeInTheDocument();
});
@@ -730,21 +684,22 @@ describe('DashboardPage', () => {
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /archived/i }));
await user.click(screen.getByText('Archive'));
await waitFor(() => {
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
});
const restoreBtn = screen.getByRole('button', { name: /restore/i });
await user.click(restoreBtn);
// An archived card's archive action is labelled "Restore" and restores the trip
const card = screen.getByText('Restored Trip').closest('.trip-card') as HTMLElement;
await user.click(card.querySelector('[aria-label="Restore"]') as HTMLElement);
// After restore, the archived section should disappear (no archived trips left)
// After restore the archive filter has nothing left to show
await waitFor(() => {
expect(screen.queryByRole('button', { name: /archived/i })).not.toBeInTheDocument();
expect(screen.queryByText('Restored Trip')).not.toBeInTheDocument();
});
});
});
@@ -765,8 +720,8 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
});
// Find copy buttons (may appear in mobile + desktop)
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
// Find duplicate buttons (the copy action is labelled "Duplicate")
const copyButtons = screen.getAllByRole('button', { name: /duplicate/i });
expect(copyButtons.length).toBeGreaterThan(0);
await user.click(copyButtons[0]);
@@ -791,10 +746,10 @@ describe('DashboardPage', () => {
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getByText(/no trips yet/i)).toBeInTheDocument();
expect(screen.getByText(/plan a new trip from scratch/i)).toBeInTheDocument();
});
// Empty state should show a descriptive text and a create button
// The add-trip card and the floating action button both offer a way to create a trip
const createButtons = screen.getAllByRole('button');
const createBtn = createButtons.find(btn => btn.textContent?.toLowerCase().includes('trip'));
expect(createBtn).toBeDefined();
@@ -828,16 +783,15 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('Live Adventure').length).toBeGreaterThan(0);
});
// Stats section: places count "5" and buddies count "2" appear
// Boarding pass summarises destinations and travelers for the spotlight trip
await waitFor(() => {
expect(screen.getAllByText('5').length).toBeGreaterThan(0);
expect(screen.getAllByText('2').length).toBeGreaterThan(0);
expect(screen.getByText(/5 destinations/i)).toBeInTheDocument();
// shared_count (2) + the owner = 3 travelers
expect(screen.getByText(/3 travelers/i)).toBeInTheDocument();
});
// Days stat label
expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
// Places stat label
expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0);
// The countdown ring labels the remaining days of the ongoing trip
expect(screen.getAllByText(/days left/i).length).toBeGreaterThan(0);
});
});
@@ -857,7 +811,6 @@ describe('DashboardPage', () => {
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
route_calculation: false,
blur_booking_codes: false,
dashboard_currency: 'on',
dashboard_timezone: 'on',
File diff suppressed because it is too large Load Diff
+13 -57
View File
@@ -1,71 +1,28 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { tripRepo } from '../repo/tripRepo'
import { placeRepo } from '../repo/placeRepo'
import Navbar from '../components/Layout/Navbar'
import React from 'react'
import { Link } from 'react-router-dom'
import PageShell from '../components/Layout/PageShell'
import { PageSpinner } from '../components/shared/Spinner'
import FileManager from '../components/Files/FileManager'
import { ArrowLeft } from 'lucide-react'
import { useTranslation } from '../i18n'
import type { Trip, Place, TripFile } from '../types'
import { useFiles } from './files/useFiles'
export default function FilesPage(): React.ReactElement {
const { t } = useTranslation()
const { id: tripId } = useParams<{ id: string }>()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState<Trip | null>(null)
const [places, setPlaces] = useState<Place[]>([])
const [files, setFiles] = useState<TripFile[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async (): Promise<void> => {
setIsLoading(true)
try {
const [tripData, placesData] = await Promise.all([
tripRepo.get(tripId),
placeRepo.list(tripId),
])
setTrip(tripData.trip)
setPlaces(placesData.places)
await tripStore.loadFiles(tripId)
} catch (err: unknown) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setFiles(tripStore.files)
}, [tripStore.files])
const handleUpload = async (formData: FormData): Promise<void> => {
await tripStore.addFile(tripId, formData)
}
const handleDelete = async (fileId: number): Promise<void> => {
await tripStore.deleteFile(tripId, fileId)
}
// Page = wiring container: trip/places load, file sync + handlers live in the hook.
const { tripId, navigate, trip, places, files, isLoading, handleUpload, handleDelete } = useFiles()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
</div>
<PageSpinner
wrapperClassName="min-h-screen flex items-center justify-center bg-slate-50"
className="w-10 h-10 border-4 border-slate-200 border-t-slate-700"
/>
)
}
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<PageShell className="bg-slate-50" navbar={{ tripTitle: trip?.name, tripId, showBack: true, onBack: () => navigate(`/trips/${tripId}`) }}>
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center gap-3 mb-6">
<Link
@@ -92,7 +49,6 @@ export default function FilesPage(): React.ReactElement {
tripId={tripId}
/>
</div>
</div>
</div>
</PageShell>
)
}
+4 -33
View File
@@ -1,8 +1,7 @@
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import React, { ChangeEvent } from 'react'
import { Mail, ArrowLeft, CheckCircle2, Terminal } from 'lucide-react'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { useForgotPassword } from './forgotPassword/useForgotPassword'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 38px', borderRadius: 12,
@@ -13,36 +12,8 @@ const inputBase: React.CSSProperties = {
const ForgotPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [smtpConfigured, setSmtpConfigured] = useState<boolean | null>(null)
useEffect(() => {
// Probe whether SMTP is configured so we can warn the user up-front
// that the link will land in the server console instead of their
// inbox. Null while pending — hint is hidden until we know.
authApi.getAppConfig?.()
.then((cfg: any) => {
const hasEmail = !!cfg?.available_channels?.email
setSmtpConfigured(hasEmail)
})
.catch(() => setSmtpConfigured(null))
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return
setIsLoading(true)
try {
await authApi.forgotPassword({ email: email.trim() })
} catch {
// Enumeration-safe: success UX regardless of server outcome.
}
setSubmitted(true)
setIsLoading(false)
}
// Page = wiring container: form state, the SMTP probe and submit live in the hook.
const { navigate, email, setEmail, submitted, isLoading, smtpConfigured, handleSubmit } = useForgotPassword()
return (
<div style={{
+15 -46
View File
@@ -1,53 +1,23 @@
import React, { useEffect, useRef, useState } from 'react'
import React from 'react'
import { Bell, CheckCheck, Trash2 } from 'lucide-react'
import { useTranslation } from '../i18n'
import { useInAppNotificationStore } from '../store/inAppNotificationStore.ts'
import { useSettingsStore } from '../store/settingsStore'
import Navbar from '../components/Layout/Navbar'
import PageShell from '../components/Layout/PageShell'
import { Spinner } from '../components/shared/Spinner'
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
export default function InAppNotificationsPage(): React.ReactElement {
const { t } = useTranslation()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const { notifications, unreadCount, total, isLoading, hasMore, fetchNotifications, markAllRead, deleteAll } = useInAppNotificationStore()
const [unreadOnly, setUnreadOnly] = useState(false)
const loaderRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetchNotifications(true)
}, [])
// Reload when filter changes
useEffect(() => {
// We need to fetch with the unreadOnly filter — re-fetch from scratch
// The store fetchNotifications doesn't take a filter param directly,
// so we use the API directly for filtered view via a side channel.
// For now, reset and fetch — store always loads all, filter is client-side.
fetchNotifications(true)
}, [unreadOnly])
// Infinite scroll
useEffect(() => {
if (!loaderRef.current) return
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !isLoading) {
fetchNotifications(false)
}
}, { threshold: 0.1 })
observer.observe(loaderRef.current)
return () => observer.disconnect()
}, [hasMore, isLoading])
const displayed = unreadOnly ? notifications.filter(n => !n.is_read) : notifications
// Page = wiring container: store, filter, fetch + infinite scroll live in the hook.
const {
notifications, unreadCount, total, isLoading, hasMore,
unreadOnly, setUnreadOnly, loaderRef, displayed,
markAllRead, deleteAll,
} = useInAppNotifications()
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ paddingTop: 'var(--nav-h)' }}>
<div className="max-w-2xl mx-auto px-4 py-8">
<PageShell background="var(--bg-primary)">
<div className="max-w-2xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
@@ -122,7 +92,7 @@ export default function InAppNotificationsPage(): React.ReactElement {
>
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-slate-200 border-t-current rounded-full animate-spin" />
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
@@ -139,12 +109,11 @@ export default function InAppNotificationsPage(): React.ReactElement {
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-current rounded-full animate-spin" />}
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
</div>
)}
</div>
</div>
</div>
</div>
</PageShell>
)
}
+63 -267
View File
@@ -1,7 +1,9 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters'
import { normalizeImageFiles } from '../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
@@ -29,6 +31,8 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { getApiErrorMessage } from '../types'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -85,250 +89,22 @@ function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thu
}
export default function JourneyDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
// Role-based permissions (server-provided via my_role). Fall back to
// "owner" when the field isn't present yet (legacy responses) so behavior
// matches the pre-permissions era.
const myRole = (current as any)?.my_role ?? 'owner'
const canEditEntries = myRole === 'owner' || myRole === 'editor'
const canEditJourney = myRole === 'owner'
const [view, setView] = useState<'timeline' | 'gallery'>('timeline')
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const feedRef = useRef<HTMLDivElement>(null)
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
const [showInvite, setShowInvite] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
useEffect(() => {
if (id) loadJourney(Number(id)).catch(() => {})
}, [id])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
// WebSocket real-time updates
useEffect(() => {
if (!id) return
const journeyId = Number(id)
const handler = (event: Record<string, unknown>) => {
const type = event.type as string
if (!type?.startsWith('journey:')) return
if (event.journeyId !== journeyId) return
// reload journey data on any change from other contributors
loadJourney(journeyId)
}
addListener(handler)
return () => removeListener(handler)
}, [id])
// scroll sync with map — the sticky map on the right follows whichever
// entry the user is currently reading in the feed on the left. We use
// scroll position (not IntersectionObserver) because short text-only
// entries pass through any IO band too quickly to reliably register.
const rafRef = useRef<number | null>(null)
const scrollCleanupRef = useRef<(() => void) | null>(null)
// Suppress scroll-sync updates while a programmatic smooth-scroll is
// running (triggered by a marker click). The scroll-progress reference
// line doesn't align with `scrollIntoView({ block: 'center' })`, so the
// sync would otherwise pick random entries as the scroll animates past
// them and end up nowhere near the clicked marker.
const suppressScrollSyncRef = useRef(false)
const suppressTimerRef = useRef<number | null>(null)
const setupScrollSync = useCallback(() => {
scrollCleanupRef.current?.()
const feed = feedRef.current
if (!feed) return
const commitWinner = () => {
if (suppressScrollSyncRef.current) return
const nodes = document.querySelectorAll('[data-entry-id]')
if (nodes.length === 0) return
const feedRect = feed.getBoundingClientRect()
// Reference line tracks scroll progress — at the top of the feed
// it sits at the top edge; at the bottom it sits at the bottom
// edge. This keeps every entry passing through the line exactly
// once even when they're too short to cross a static line before
// the feed runs out of scroll.
const maxScroll = feed.scrollHeight - feed.clientHeight
const progress = maxScroll > 0 ? feed.scrollTop / maxScroll : 0
const referenceY = feedRect.top + feedRect.height * progress
let lastPast: { id: string; top: number } | null = null
let firstAhead: { id: string; top: number } | null = null
nodes.forEach(el => {
const entryId = el.getAttribute('data-entry-id')
if (!entryId) return
const top = el.getBoundingClientRect().top
if (top <= referenceY) {
if (!lastPast || top > lastPast.top) lastPast = { id: entryId, top }
} else {
if (!firstAhead || top < firstAhead.top) firstAhead = { id: entryId, top }
}
})
const winner = lastPast || firstAhead
if (winner) {
setActiveEntryId(winner.id)
if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
}
}
const onScroll = () => {
if (rafRef.current != null) return
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
commitWinner()
})
}
feed.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
// prime once so the map syncs on initial load
commitWinner()
scrollCleanupRef.current = () => {
feed.removeEventListener('scroll', onScroll)
window.removeEventListener('scroll', onScroll)
if (rafRef.current != null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [])
useEffect(() => {
if (current?.entries?.length) {
const t = window.setTimeout(setupScrollSync, 300)
return () => {
window.clearTimeout(t)
scrollCleanupRef.current?.()
}
}
return () => scrollCleanupRef.current?.()
}, [current?.entries, setupScrollSync])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
if (!el) return
// Commit the choice immediately so the highlighted marker stays pinned
// to the clicked entry even while smooth-scroll passes over others.
suppressScrollSyncRef.current = true
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
// Smooth scroll typically finishes within ~500ms; 750ms gives a safety
// buffer so the sync doesn't snap back to the wrong entry on the very
// last frame.
suppressTimerRef.current = window.setTimeout(() => {
suppressScrollSyncRef.current = false
suppressTimerRef.current = null
}, 750)
}, [])
useEffect(() => () => {
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
}, [])
const handleLocationClick = useCallback((id: string) => {
setActiveLocationId(id)
}, [])
useEffect(() => {
// give the sidebar map a chance to recalc its size when the view switches
// (feed column width can shift slightly if the gallery vs timeline
// renders with a different scrollbar state).
requestAnimationFrame(() => mapRef.current?.invalidateSize())
}, [view])
// On desktop we run a two-pane layout where only the feed column scrolls;
// the body must not scroll underneath it. Restore on unmount.
useEffect(() => {
if (isMobile) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [isMobile])
// Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot.
const mapEntries = useMemo(
() => (current?.entries || []).filter(e =>
e.location_lat && e.location_lng &&
e.title !== 'Gallery' &&
e.title !== '[Trip Photos]' &&
e.type !== 'skeleton'
),
[current?.entries]
)
const sidebarMapItems = useMemo(() => {
const allDates = [...new Set(
(current?.entries || [])
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
.map(e => e.entry_date)
.sort()
)]
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const dayCounters = new Map<string, number>()
return sorted.map(e => {
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const tripDates = useMemo(() => {
const dates = new Set<string>()
if (!current?.trips) return dates
for (const trip of current.trips) {
if (!trip.start_date || !trip.end_date) continue
const start = new Date(trip.start_date + 'T00:00:00')
const end = new Date(trip.end_date + 'T00:00:00')
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
dates.add(d.toISOString().split('T')[0])
}
}
return dates
}, [current?.trips])
// Page = wiring container: load + live sync, view state, dialogs, the
// scroll-synced map and the map/trip-date derivations live in the hook.
const {
id, navigate, toast, t, locale,
current, loading,
canEditEntries, canEditJourney, myRole,
view, setView, activeEntryId, setActiveEntryId, feedRef,
viewingEntry, setViewingEntry, editingEntry, setEditingEntry,
lightbox, setLightbox, deleteTarget, setDeleteTarget,
showInvite, setShowInvite, showAddTrip, setShowAddTrip,
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
} = useJourneyDetail()
if (loading || !current) {
return (
@@ -746,8 +522,8 @@ export default function JourneyDetailPage() {
}
return entryId
}}
onUploadPhotos={async (entryId, formData) => {
return await uploadPhotos(entryId, formData)
onUploadPhotos={async (entryId, files, cbs) => {
return await uploadPhotos(entryId, files, cbs)
}}
onDone={() => {
setEditingEntry(null)
@@ -985,7 +761,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryUploading, setGalleryUploading] = useState(false)
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
const galleryUploading = galleryProgress !== null
const toast = useToast()
// check which providers are enabled AND connected for the current user
@@ -1025,17 +802,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
setGalleryUploading(true)
setGalleryProgress({ done: 0, total: files.length })
try {
const formData = new FormData()
for (const f of files) formData.append('photos', f)
await journeyApi.uploadGalleryPhotos(journeyId, formData)
toast.success(t('journey.photosUploaded', { count: files.length }))
const normalized = await normalizeImageFiles(files)
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
})
if (failed.length > 0) {
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
} else {
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
}
onRefresh()
} catch {
toast.error(t('journey.settings.coverFailed'))
} catch (err) {
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
} finally {
setGalleryUploading(false)
setGalleryProgress(null)
}
e.target.value = ''
}
@@ -1080,7 +862,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
>
{galleryUploading ? (
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
) : (
<><Plus size={12} /> {t('common.upload')}</>
)}
@@ -1769,7 +1551,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery')
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
{/* Header */}
@@ -2169,10 +1951,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
galleryPhotos: GalleryPhoto[]
onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
onDone: () => void
}) {
const { t } = useTranslation()
const toast = useToast()
const isMobile = useIsMobile()
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
@@ -2191,7 +1974,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
@@ -2244,9 +2027,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
})
// upload queued files after entry is created
if (pendingFiles.length > 0 && entryId) {
const formData = new FormData()
for (const f of pendingFiles) formData.append('photos', f)
await onUploadPhotos(entryId, formData)
const filesToUpload = pendingFiles
setUploadProgress({ done: 0, total: filesToUpload.length })
try {
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
})
setPendingFiles(failed)
if (failed.length > 0) {
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
} finally {
setUploadProgress(null)
}
}
// link gallery photos that were picked before save
if (pendingLinkIds.length > 0 && entryId) {
@@ -2265,7 +2060,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
if (!files?.length) return
// Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence.
setPendingFiles(prev => [...prev, ...Array.from(files)])
const normalized = await normalizeImageFiles(files)
setPendingFiles(prev => [...prev, ...normalized])
}
return (
@@ -2300,11 +2096,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<div className="flex gap-2">
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
disabled={saving}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
>
{uploading ? (
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
{uploadProgress ? (
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
) : (
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
)}
+34
View File
@@ -194,6 +194,40 @@ describe('JourneyPage', () => {
});
});
// FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates
// a user-controlled trip title into an HTML template that is later passed to
// dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script
// payload, otherwise renaming a trip is a one-click XSS for anyone visiting
// the Journey page.
it('FE-PAGE-JOURNEY-007b: sanitises script payloads in suggestion title', async () => {
const malicious = {
id: 1337,
title: '<img src=x onerror=alert(1)><script>window.__pwned=true</script>',
start_date: '2026-03-01',
end_date: '2026-04-01',
place_count: 3,
};
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({ journeys: [] })
),
http.get('/api/journeys/suggestions', () =>
HttpResponse.json({ trips: [malicious] })
),
);
render(<JourneyPage />);
await waitFor(() => {
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
});
// The script tag must not survive the sanitiser anywhere in the rendered DOM.
expect(document.querySelector('script')).toBeNull();
expect(document.querySelector('img[onerror]')).toBeNull();
// And the side effect would only fire if onerror executed.
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
});
// FE-PAGE-JOURNEY-008
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
+16 -83
View File
@@ -1,16 +1,12 @@
import { useEffect, useState, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { journeyApi } from '../api/client'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { useTranslation } from '../i18n'
import PageShell from '../components/Layout/PageShell'
import { useTranslation, TransHtml } from '../i18n'
import {
Plus, Search, Sparkles, Calendar, MapPin, BookOpen, Camera,
Check, X, ChevronRight, RefreshCw, Users,
} from 'lucide-react'
import type { Journey } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourney } from './journey/useJourney'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -35,82 +31,20 @@ function timeAgo(timestamp: number, t: (k: string, p?: any) => string): string {
}
export default function JourneyPage() {
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [availableTrips, setAvailableTrips] = useState<any[]>([])
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// suggestion
const [suggestions, setSuggestions] = useState<any[]>([])
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
useEffect(() => {
loadJourneys()
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
}, [])
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
const activeJourney = useMemo(() => {
if (searchQuery.trim()) return null
return journeys.find(j => {
const j2 = j as any
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
}) || null
}, [journeys, searchQuery])
const filteredJourneys = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
return journeys.filter(j => {
const inTitle = j.title.toLowerCase().includes(q)
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
return inTitle || inSubtitle
})
}, [journeys, activeJourney, searchQuery])
const openCreateModal = async (preSelectedTripId?: number) => {
setShowCreate(true)
setNewTitle('')
const initial = new Set<number>()
if (preSelectedTripId) initial.add(preSelectedTripId)
setSelectedTripIds(initial)
try {
const data = await journeyApi.availableTrips()
setAvailableTrips(data.trips || [])
} catch {}
}
const handleCreate = async () => {
if (!newTitle.trim()) return
try {
const j = await createJourney({
title: newTitle.trim(),
trip_ids: [...selectedTripIds],
})
setShowCreate(false)
navigate(`/journey/${j.id}`)
} catch {
toast.error(t('journey.createError'))
}
}
const totalPlaces = useMemo(() => {
return availableTrips.filter(t => selectedTripIds.has(t.id)).reduce((sum: number, t: any) => sum + (t.place_count || 0), 0)
}, [availableTrips, selectedTripIds])
// Page = wiring container: store load, create modal, search + suggestions in the hook.
const {
navigate, journeys, loading,
showCreate, setShowCreate, newTitle, setNewTitle,
availableTrips, selectedTripIds, setSelectedTripIds,
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
activeSuggestion, setDismissedSuggestions,
activeJourney, filteredJourneys,
openCreateModal, handleCreate, totalPlaces,
} = useJourney()
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
<PageShell className="bg-zinc-50 dark:bg-zinc-950" navOffset="var(--nav-h, 56px)">
<div className="max-w-[1440px] mx-auto">
{/* Header — mobile */}
@@ -203,7 +137,7 @@ export default function JourneyPage() {
<div>
<div className="text-[10px] font-semibold tracking-[0.12em] uppercase opacity-70">{t("journey.frontpage.suggestionLabel")}</div>
<div className="text-[13px] mt-0.5">
<span dangerouslySetInnerHTML={{ __html: t('journey.frontpage.suggestionText', { title: activeSuggestion.title }) }} />
<TransHtml html="journey.frontpage.suggestionText" params={{ title: activeSuggestion.title }} />
</div>
</div>
</div>
@@ -345,7 +279,6 @@ export default function JourneyPage() {
)}
</div>
</div>
</div>
{/* Create Modal */}
{showCreate && (
@@ -453,7 +386,7 @@ export default function JourneyPage() {
</div>
</div>
)}
</div>
</PageShell>
)
}

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