Compare commits

..

324 Commits

Author SHA1 Message Date
jubnl 6bcdfbc34b chore: apply prettier on the entire project 2026-05-25 21:59:42 +02:00
Julien G. c130ed41be chore: fix monorepo build pipeline and migrate shared to built package (#1056)
* chore: fix monorepo build pipeline and migrate shared to built package

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit 67cf290cda.

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

This reverts commit f92b95e054.

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

This reverts commit 797183de08.

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

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

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

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

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

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

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

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

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

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

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

Fixes #1005

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

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

Fixes #1002

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update emails in security.md

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

* chore: regenerate lock files

* chore: correct secret names

* chore: pr perms write

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes discussion #836.

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

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

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

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

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

* chore: remove committed build artifacts from server/public

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

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

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

* fix: correct package.json path in APP_VERSION fallback

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

Closes #939

* chore: prepare database for nest + typeorm

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

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

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

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

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

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

Reverts the isAlwaysBlocked guard loosening from 9a08368.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #889

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

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

* fix: delete surplus days when shortening a trip

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

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

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

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

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

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

* feat: add bypass-branch-check label to skip branch enforcement
2026-04-28 05:17:20 +02:00
jubnl ca832e8d88 chore: prevent new build on workflow change 2026-04-27 00:31:22 +02:00
jubnl 12fc7f7b68 docs: fix Proxmox update section to run inside LXC and add command 2026-04-27 00:28:48 +02:00
github-actions[bot] 2770a189df chore: bump version to 3.0.10 [skip ci] 2026-04-26 22:22:31 +00:00
jubnl 2b162a8cc7 chore: reset to 3.0.9 2026-04-27 00:22:09 +02:00
github-actions[bot] 009d89fecf chore: bump version to 3.0.10 [skip ci] 2026-04-26 22:15:15 +00:00
jubnl 5c3b89578d docs: add Proxmox VE LXC install guide and update CI ignore paths
- Add wiki/Install-Proxmox.md with full install/update/log instructions
- Add Proxmox VE section to wiki/Updating.md
- Add Install: Proxmox VE (LXC) to wiki/_Sidebar.md
- Add "Proxmox Community Script" option to bug report install dropdown
- Exclude GitHub meta files from triggering Docker CI workflow
2026-04-27 00:14:50 +02:00
github-actions[bot] 303e7de433 chore: bump version to 3.0.9 [skip ci] 2026-04-26 19:59:33 +00:00
Maurice 08eb7f3733 Merge pull request #892 from mauriceboe/fixes-26-04-2026
fixes-26-04-2026
2026-04-26 21:59:21 +02:00
jubnl 90d86eda61 chore: Add Trademark policy 2026-04-26 15:36:34 +02:00
jubnl 0eca6d54a1 chore: Add Trademark policy 2026-04-26 15:27:33 +02:00
Julien G. bc1fb71391 Fix exit code 132 on old CPUs by replacing sharp with jimp (issue #888) (#895)
sharp's prebuilt Linux x64 binary requires SSE4.2 (x86-64-v2), causing a
SIGILL crash on older hardware (e.g. AMD A6-3420M). Replace with jimp, a
pure-JS image library with no native binaries. Also skip thumbnail generation
entirely when the Journey addon is disabled (the default), preventing the
issue for most installs regardless of the image library used.
2026-04-26 13:26:09 +02:00
Maurice cb425fb397 Fix 500 on reservation edit after DB reinit (issue #883)
saveEndpoints was bound at module load via db.transaction(...). When the
demo-mode hourly reset (or a self-hoster's backup restore) closes the DB
connection and reinitialises it, the bound transaction still references
the now-closed connection — every subsequent reservation save with an
endpoints field throws "The database connection is not open", which the
client surfaces as "Internal server error".

Bind the transaction lazily on each call so it always runs against the
current connection.
2026-04-26 12:14:17 +02:00
Maurice 35ed712d46 Fix demo banner overlapping bottom tab bar on mobile
The demo welcome modal extended below the mobile bottom tab bar,
hiding the dismiss button so visitors couldn't close it.

- Use dvh so mobile URL bar is accounted for correctly
- Reserve ~80px of bottom padding for the tab bar
- Make the footer sticky so the dismiss button stays visible
  while scrolling through the modal content
- Bump z-index to ensure the overlay sits above the tab bar
2026-04-26 12:02:25 +02:00
jubnl 4923973380 docs(wiki): add MCP OAuth troubleshooting entry for missing APP_URL 2026-04-23 20:02:32 +02:00
github-actions[bot] 8342cf3010 chore: bump version to 3.0.8 [skip ci] 2026-04-23 17:49:49 +00:00
Julien G. 2a37eeccb3 fix: hot fixes 23-04-2026 (#856)
* fix(packing): resolve avatar URL path in bag and category assignees (#854)

packingService was returning raw avatar filenames from the DB instead of
the full /uploads/avatars/<filename> path, causing broken profile images
for users with uploaded avatars.

* fix(budget): use Map.get() to fix category rename no-op (#855)

* fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863)

- Change Helmet default from no-referrer to strict-origin-when-cross-origin
  so browsers send the origin on cross-origin requests, allowing Google Maps
  API key restrictions by HTTP referrer to work correctly
- Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts:
  .env.example, docker-compose.yml, README.md, unraid-template.xml,
  charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md

* fix(planner): prefetch budget items on trip page mount (#864)

Loads budgetItems alongside reservations when TripPlannerPage mounts so
the Budget category dropdown in ReservationModal and TransportModal shows
pre-existing categories on first open, regardless of whether the Budget
tab has been visited.

Closes #861

* fix(reservations): prevent Invalid Date when end time is set without end date (#866)

When reservation_end_time held a bare time string ("HH:MM"), fmtDate()
produced Invalid Date on the reservation card.

- Modal: when end date is blank but end time is filled, construct a
  same-day ISO datetime using the start date (prevents time-only strings
  from ever being persisted)
- Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD")
  still show the multi-day range, while bare time strings are skipped and
  handled correctly by the existing time column logic

Closes #860

* fix(planner): format reservation end time instead of rendering raw ISO string (#867)

Closes #859

* fix(planner): wire Route toggle into mobile day sidebar (#850) (#868)

The per-booking Route icon was missing on mobile because the mobile
DayPlanSidebar invocation in TripPlannerPage didn't pass
visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't
activate reservation map overlays without forcing desktop mode.

Also corrects the Map-Features wiki: fixes the setting name
("Booking route labels" not "Show connection labels"), documents the
route_calculation requirement for travel-time pills, and explains that
overlays are off by default and must be toggled per reservation.
2026-04-23 19:49:36 +02:00
github-actions[bot] ae0e59d9f1 chore: bump version to 3.0.7 [skip ci] 2026-04-23 09:07:40 +00:00
Maurice 50bb7573fd [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849)
* fix: server/package.json & server/package-lock.json to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-UUID-16133035

* fix: bump fast-xml-parser version

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: jubnl <jgunther021@gmail.com>
2026-04-23 11:07:25 +02:00
github-actions[bot] b852317c84 chore: bump version to 3.0.6 [skip ci] 2026-04-23 08:53:44 +00:00
Julien G. 4436b6f673 fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848)
* fix(journey): make sort_order authoritative for within-day entry ordering

Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.

- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019

Closes #846

* fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847)

Reservations were matched to days by pickup date only, so the end-day
card (e.g. car Return, flight Arrival) was silently dropped from the PDF.
Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id
span, show reservation_end_time on end days, prefix title with phase label
(Return/Arrival/etc.), and use per-day position for sort order.

* test(pdf): add missing day_id to transport reservation fixture
2026-04-23 10:53:32 +02:00
github-actions[bot] 311647fd46 chore: bump version to 3.0.5 [skip ci] 2026-04-23 08:07:13 +00:00
Xre0uS 28dbd86d03 fix(files): open attachments only in new tab (#840)
window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.
2026-04-23 10:06:56 +02:00
github-actions[bot] 842d9760df chore: bump version to 3.0.4 [skip ci] 2026-04-23 07:13:48 +00:00
Julien G. 58218ff5f6 fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845)
OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.

UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.

Closes #843
Closes #844
2026-04-23 09:13:35 +02:00
github-actions[bot] 83be5fc92a chore: bump version to 3.0.3 [skip ci] 2026-04-22 20:16:47 +00:00
Julien G. 7798d2a3fd fix(oidc): normalize id_token iss claim before issuer comparison (#837)
jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.

Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.

Closes #834
2026-04-22 22:16:33 +02:00
github-actions[bot] ec1ed60117 chore: bump version to 3.0.2 [skip ci] 2026-04-22 19:25:28 +00:00
Julien G. ed4c21eade Merge pull request #835 from mauriceboe/fix/oidc-issuer-trailing-slash
fix(oidc): normalize discovery doc issuer before trailing slash comparison
2026-04-22 21:25:15 +02:00
jubnl 9093948ff6 test(systemNotices): exclude v3 upgrade notices from login_count-only tests
Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.
2026-04-22 21:19:04 +02:00
jubnl 2cea4d73aa fix(oidc): normalize discovery doc issuer before comparison
Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834
2026-04-22 21:14:29 +02:00
github-actions[bot] a2a6f52e6e chore: bump version to 3.0.1 [skip ci] 2026-04-22 17:58:18 +00:00
Maurice 0978b40b6d Merge pull request #832 from mauriceboe/fix/reservations-day-id-mismatch
fix(reservations): restore correct day assignment for non-transport bookings
2026-04-22 19:58:03 +02:00
Maurice 6155b6dc86 fix(reservations): restore correct day assignment for non-transport bookings
v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.
2026-04-22 19:47:22 +02:00
jubnl 314486325e fix: resolve dead wiki links across install and config pages 2026-04-22 19:21:53 +02:00
github-actions[bot] 523bca3a20 chore: bump version to 3.0.0 [skip ci] 2026-04-22 16:59:12 +00:00
Maurice d5be528d4b Merge pull request #758 from mauriceboe/dev
V3.0.0
2026-04-22 18:58:23 +02:00
Julien G. 3ada075b1a Merge pull request #831 from mauriceboe/fix/transport-modal-price-budget-fields
fix: restore Price and Budget Category fields in Edit Transport dialog
2026-04-22 18:55:53 +02:00
jubnl afce302b59 fix: restore price and budget category fields in TransportModal 2026-04-22 18:50:42 +02:00
Maurice 8e8433fa9d docs: align Home.md + README addon list + Tags/Photo-Providers wording with dev state
- Home.md: addon list (9 real addons), MCP numbers (150+ tools, 30 resources, 27 scopes), admin-seeding text
- README.md: expand addon list from 5 to 9 (Lists/Budget/Documents/Naver/MCP in, Dashboard widgets out)
- Photo-Providers.md: 'Memories addon' -> photo provider toggles under Journey
- Admin-Addons.md: Journey works without photo providers; they are optional sub-toggles
- Tags-and-Categories.md: add Personal Tags section (user-scoped, MCP-only for now)
2026-04-22 18:22:22 +02:00
Maurice ff42fa0b8c docs: sync README with current dev state
- MCP: 80+ tools/27 resources -> 150+ tools/30 resources
- MCP: 24 -> 27 OAuth scopes
- i18n: 14 -> 15 languages
- admin seeding on first boot (not first-to-register)
- nginx: client_max_body_size 50m -> 500m, add proxy_read_timeout 86400 on /ws
2026-04-22 18:10:27 +02:00
jubnl ccea7f7a65 fix: restore map share toggle and fix public journey horizontal scroll
Re-adds the share_map permission toggle to the journey share settings UI so
owners can control whether the map is visible on the public share page.
Fixes horizontal scrollbar on the public journey page caused by decorative
hero circles with negative offsets overflowing the viewport.
2026-04-22 17:05:15 +02:00
jubnl 45a5b4e588 fix: remove obsolete map share toggle and make public desktop entries openable
Map permission is always enabled on new links (share always includes map).
Removed the toggle from the share settings UI since the map is now always
part of the combined timeline+map view with no standalone value in toggling it.

Desktop entry cards on the public share page now open MobileEntryView on click,
matching the mobile behaviour added in #826.
2026-04-22 16:33:04 +02:00
jubnl 82cce365f7 fix: validate image-only uploads and respect allowed_file_types setting for journey photos
Add fileFilter to the journey photo multer config (shared by entry photo
upload and gallery upload routes):
- Rejects any non-image MIME type (including SVG which carries XSS risk)
- Checks the extension against the admin-configured allowed_file_types setting
  (same getAllowedExtensions() used by the trip file upload route)
- Returns HTTP 400 with a descriptive message on rejection

Also fix the global error handler to return err.message for 4xx responses
instead of the generic 'Internal server error', so fileFilter rejections
produce a readable error on the client.
2026-04-22 16:16:35 +02:00
jubnl ed7e2badca fix: catch sharp errors in ensureLocalThumbnail and fall back to original
Sharp throws on unsupported formats (HEIC, corrupt files, etc.) and the
error was propagated outside the try/catch, crashing the server. Moved the
mkdir + sharp pipeline inside the catch block so any failure returns null
and streamPhoto falls through to serving the original file.
2026-04-22 16:11:38 +02:00
jubnl ba7b99fb7d fix: update backend tests and service bugs for gallery 1-to-N schema
updatePhoto: write sort_order to journey_entry_photos (junction) not journey_photos,
since JP_SELECT reads jep.sort_order — updating the gallery row had no visible effect.

deletePhoto: include id in return value so callers that check deleted.id still work.

Tests updated for new schema:
- journeyShareService: insertJourneyPhoto helper now inserts into journey_photos
  (keyed by journey_id) + journey_entry_photos junction instead of the old
  entry_id-keyed table
- SVC-081: deleteEntry cascades junction rows (journey_entry_photos), not gallery
  rows (journey_photos); assert junction is gone, gallery is preserved
- SVC-086: syncTripPhotos now populates the gallery directly — no [Trip Photos]
  wrapper entry; assert journey_photos gallery row instead
- INT-028: error message updated to 'journey_photo_id required'
2026-04-22 16:05:18 +02:00
jubnl 71aa8f8051 feat: journey gallery 1-to-N model with M:N entry-photo junction table
Replaces the old model where journey_photos was keyed per-entry with a
per-journey gallery table (one row per unique photo per journey) and a new
junction table journey_entry_photos that links gallery photos to entries.

Key changes:
- Migration 121: renames old journey_photos to journey_photos_old, creates the
  new gallery table + junction table, backfills both from existing data, drops
  the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries
- journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via
  journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos,
  addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies
  deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts
  directly into the gallery instead of a wrapper entry
- journeyShareService: updates public photo and asset validation queries to join
  through the gallery table instead of entry_id; getPublicJourney now returns a
  dedicated gallery array alongside per-entry photos
- journey routes: adds gallery upload, provider-photo, and delete endpoints
  (POST/DELETE /:id/gallery/*); adds unlink-from-entry route
  (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to
  accept journey_photo_id with a backwards-compat photo_id alias
- types: adds GalleryPhoto interface
- client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto,
  deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId
- journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail,
  uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions
- JourneyDetailPage + tests: updated to work with the new gallery model
2026-04-22 15:58:31 +02:00
jubnl 7c9e945b8c fix: serve real thumbnails for local photos instead of full-resolution originals (#822)
Add thumbnailService that lazy-generates a WebP thumbnail (800px max, q80) on
first GET /api/photos/:id/thumbnail request using sharp. The generated file is
stored at uploads/journey/thumbs/<sha1>.webp and the path is persisted to
trek_photos.thumbnail_path so subsequent requests are served directly from disk.
Also populates width/height as a side-effect.

streamPhoto now branches on kind for local file_path rows — thumbnail requests
use the stored/generated thumb path; original requests (and fallback when thumb
generation fails) continue to serve the full file. Remote providers (Immich,
Synology) are unaffected.
2026-04-22 15:56:34 +02:00
jubnl f6b3931bc4 fix: mobile public share — remove map tab (#828), cap timeline width (#827), wire entry click (#826)
- #828: exclude 'map' from availableViews on mobile; MobileMapTimeline already
  shows combined map+timeline so the standalone map tab is redundant
- #827: cap timeline feed column at xl:max-w-[50%] on ≥1280px viewports so the
  map aside is not dwarfed on wide monitors; applies to both desktop two-column
  layouts (JourneyPublicPage)
- #826: wire MobileMapTimeline onEntryClick to setViewingEntry; render
  MobileEntryView with readOnly + public photo URL builder so photos load via
  the share token endpoint; add publicPhotoUrl prop to MobileEntryView so
  photo URLs are routable for both authenticated and public-share contexts
2026-04-22 15:56:20 +02:00
Maurice 9e3041305c docs: remove badge icons + Roadmap board->view 2026-04-22 00:00:46 +02:00
Maurice 78fc557143 docs: remove icons from badges 2026-04-22 00:00:27 +02:00
Maurice 8a2fec8de0 docs: shorten badge labels (Demo/Try, Discord/Join, Ko-fi/Support, BMAC/Support) 2026-04-21 23:58:49 +02:00
Maurice e109dc0b51 docs: subtitle onto its own line under the logo + Ko-fi/BMAC badges
- <br /> between the TREK logo and the subtitle picture so the
  subtitle sits below the logo instead of rendering next to it.
- New badge row with Ko-fi and Buy Me a Coffee in the same
  for-the-badge style as Live Demo / Docker / Discord / Roadmap.
2026-04-21 23:39:54 +02:00
Julien G. 88d980c657 Merge pull request #820 from mauriceboe/fix/802-819-journey-gallery-mobile-fixes
fix(journey): dedupe gallery photos and fix Immich picker button visibility on mobile (#802 #819)
2026-04-21 23:32:24 +02:00
jubnl 3f489880da fix(journey): dedupe gallery photos and fix Immich picker button visibility on mobile (#802 #819)
Fix #802: ProviderPicker modal now uses dvh-based max-height, items-end
on mobile (bottom-sheet), flex-shrink-0 on all fixed sections, min-h-0
on the scrollable grid, and env(safe-area-inset-bottom) padding so the
Add button is always reachable above the iOS home indicator.

Fix #819: Gallery view now deduplicates photos by photo_id (underlying
trek_photos.id) so a photo linked from Gallery into an activity no longer
appears twice. Gallery delete cascades to all copies. EntryEditor From
Gallery grid and photo count also deduplicated. Server photo_count uses
COUNT(DISTINCT photo_id). Preserves #729 guarantee (removing from an
activity does not delete the Gallery copy).
2026-04-21 23:26:02 +02:00
Julien G. 45fa6fd0d3 Merge pull request #809 from mauriceboe/fix/789-800-journey-mobile-fixes
fix(journey): resolve issues #789–801 — mobile layout, day colors, location formatting, date picker, public share UX
2026-04-21 22:56:54 +02:00
jubnl a8c27f9d4a test: update tests to match translated share link button and desktop two-column map layout
- 'Remove share link' → 'Delete link' (now uses share.deleteLink i18n key)
- FE-PAGE-PUBLICJOURNEY-009/012: map tab no longer exists in desktop two-column
  layout; map is always rendered in the sidebar — tests updated to verify the
  journey-map testid is present without requiring a tab click
2026-04-21 22:51:48 +02:00
jubnl 288d33ba42 fix(journey/mobile): eliminate carousel scroll stutter on mobile
- Defer activeIndex updates until scrolling settles (150ms debounce)
  instead of updating every RAF — mid-swipe card resize (240→320px)
  caused layout reflow on every frame, which is the main stutter source
- Switch scrollSnapType from 'proximity' to 'mandatory' for reliable
  browser-native snapping without needing a JS re-center pass
- Remove scroll-smooth CSS class (conflicts with mandatory snap)
- Remove the post-settle scrollIntoView call (mandatory snap handles it)
- Drop the now-unused activeIndexRef

Closes #818
2026-04-21 22:42:32 +02:00
jubnl e7fb78dc1e fix(journey/settings): translate 'Remove share link' button using share.deleteLink key 2026-04-21 22:42:31 +02:00
jubnl 4d3bf390a5 feat(journey/settings): warn on unsaved changes before closing modal
- Track dirty state (title/subtitle changed from original)
- Intercept X button, backdrop click, and Cancel with handleClose
- Show ConfirmDialog when dirty; proceed with onClose only on confirm
- Add common.discardChanges and common.discard keys to all 15 locales
2026-04-21 22:42:31 +02:00
jubnl 001b2365a1 fix(journey): correct map marker color offset and scroll-sync for unlocated entries
- sidebarMapItems now derives dayIdx from all timeline dates (not just
  located-entry dates), so markers stay color-aligned with day headers
  even when some days have no location
- scroll-sync no longer calls highlightMarker for unlocated entries,
  preventing the map from clearing or misfiring when the scroll winner
  has no corresponding marker
- same dayIdx fix applied to JourneyPublicPage desktop two-column view
2026-04-21 22:42:30 +02:00
jubnl 7d5dadc441 feat(journey/public): match desktop timeline view to in-app experience 2026-04-21 22:42:30 +02:00
jubnl c912ad4b01 fix(journey): expand DAY_COLORS to 30 unique colors to cover a full month 2026-04-21 22:40:48 +02:00
jubnl bd6cd55a13 fix(journey): resolve issues #789-801 — mobile layout, day colors, location formatting, date picker, public share UX 2026-04-21 22:40:47 +02:00
Maurice 757764d046 hotfix: offline banner as bottom pill instead of full-width top bar
The top bar still blocked the trip planner's top nav on mobile even
after #808's padding trick — nav layouts that position their own
sticky headers were ignoring the --offline-banner-h offset, and the
bar looked alarming for what is usually a 2s blip.

Redesign as a small floating pill anchored bottom-center, hovering
above the mobile bottom nav (calc(var(--bottom-nav-h) + 16px)). No
layout shift anywhere, nothing ever covers the nav, and the pill
looks like a passing status chip rather than an error banner.

Reverts the body padding-top / navbar top offset introduced in #808
since they're no longer needed with the pill positioning.
2026-04-21 22:30:50 +02:00
Maurice 94e64acc34 Merge pull request #808 from mauriceboe/fix/modal-mobile-footer-visibility
fix: mobile polish batch (#803–#807, #810–#815)
2026-04-21 22:23:40 +02:00
Maurice 70ba24bfe1 fix(test): cancel Navbar theme-transition timer on unmount
The dark-mode toggle kicked off a 360ms setTimeout that removed a
CSS class via 'document.documentElement'. In vitest the document
was torn down before the timer fired, triggering an unhandled
ReferenceError that flipped the whole run to a non-zero exit even
though every test passed.

Track the handle in a ref and clearTimeout on unmount (and before
scheduling a new one).
2026-04-21 22:18:54 +02:00
Maurice 32f431e879 fix: translate months in journey timeline (#815)
formatDate() in both JourneyDetailPage and JourneyPublicPage passed
undefined/'en' as the locale to toLocaleDateString, so weekday/month
names always followed the browser's language instead of the app's
selected UI language. Thread the selected locale through from
useTranslation() in both pages.

Public view still falls back to 'en' when no settings locale is
available (shared links can be opened by anyone).
2026-04-21 22:16:43 +02:00
Maurice 906d8821a4 fix: offline banner no longer covers the top of the app (#813)
OfflineBanner was fixed at top:0 but the rest of the page had no
idea it was visible, so on mobile (and the desktop nav on wider
screens) the banner sat on top of the header content.

When the banner is visible it now sets --offline-banner-h on <html>;
body reserves that space via padding-top, and the desktop fixed
Navbar shifts its top by the same amount. When back online the var
is removed and everything snaps back.
2026-04-21 22:10:11 +02:00
Maurice 82b16a4bf5 fix(i18n): use 'polls' consistently in Dutch trip collab (#814)
Mixed 'peilingen' (titles/tabs) with 'poll/polls' (everywhere else).
Normalised to 'polls' per reporter's preference — more common in
modern Dutch usage anyway.
2026-04-21 22:05:33 +02:00
Maurice 069269e69c fix: integrations settings squish on mobile (#812) + polish
PhotoProvidersSection:
- Replace raw <input type=checkbox> with TREK's ToggleSwitch so the
  'spiegeln zu Immich'-style options match the rest of the app.
- Wrap action row in flex-wrap so the connected/disconnected badge
  drops to its own line on mobile instead of clipping.
- Add a short 'Test' translation (memories.testShort) shown on mobile
  in place of 'Test connection' — 14 languages kept in sync.

ToggleSwitch:
- Explicit type='button' (never a form submitter), minWidth + flex-
  shrink:0 so the toggle doesn't get squished next to long labels,
  padding:0 so no inherited UA margin warps the inner circle.

MapSettingsTab:
- 'Mapbox' instead of 'Mapbox GL' on narrow screens — the provider
  card is too cramped on mobile for the full name.
- Drop the 'Experimental' badge on mobile entirely; it overlapped
  the title at that width. Still shown on >=sm.

DisplaySettingsTab:
- Time format buttons show just '24h' / '12h' on mobile; the '(14:30)'
  / '(2:30 PM)' hint stays on >=sm. Test updated to match the role
  query since the label is now split across nodes.
2026-04-21 22:03:20 +02:00
Maurice 534149ba22 fix(test): query form by tag since Save button is now in Modal footer
After moving Save/Cancel into the Modal's sticky footer prop, the
button no longer lives inside the <form> element, so walking up via
closest('form') returns null. Query the form directly via
document.querySelector('form') — same semantics, just doesn't assume
the button is a descendant of the form.
2026-04-21 21:52:46 +02:00
Maurice 2dd6e04b44 fix: treat new-category placeholder name '...' as a UI placeholder (#811)
When a user adds a new packing category, the first item is seeded
with name '...' because the server rejects empty names. That string
was rendered as a real value in the input, forcing users to delete
the dots before typing. Now we detect the sentinel, show it as a
faint placeholder in the display span, and start the edit input
empty (with '...' as the HTML placeholder).
2026-04-21 21:50:56 +02:00
Maurice 0e3d9f6ddc fix: reservation card header overlap on mobile (#810)
Status and category chips collided with the reservation title on
narrow viewports because the header was a single-line flex with
inline chips of natural width. flexWrap on the outer row plus the
inner chip group lets the title+actions drop to a second row when
content overflows, so the chips and the title never overlap.
2026-04-21 21:46:58 +02:00
Maurice 3b7442c2d5 fix: bottom-nav related mobile cutoffs (#805, #806, #807)
TransportModal + ReservationModal: move Save/Cancel into the Modal's
footer slot so they stay visible on long forms (same fix as
PlaceFormModal in this PR).

DayDetailPanel: the floating day info panel was anchored at a fixed
bottom: 96px which didn't account for safe-area-inset-bottom, causing
it to overlap the bottom nav on devices with a home indicator. Use
calc(var(--bottom-nav-h) + 20px) so it always floats above the tab
bar with a safe gap.
2026-04-21 21:42:48 +02:00
Maurice 78b45d7c19 docs: replace README subtitle text with image (light/dark)
Swaps the 'Your trips. Your plan. Your server.' H3 for a rendered
subtitle image using <picture> + prefers-color-scheme, matching the
logo pattern.
2026-04-21 21:39:39 +02:00
Maurice 9e5100c71c fix: keep modal save button visible on mobile (#803, #804)
Two fixes in Modal.tsx:
- Replace 100vh with 100dvh so iOS Safari PWA respects the actual
  visible viewport. Explicitly subtract --bottom-nav-h on mobile so
  the modal never extends behind the tab bar.
- overflow-hidden on the container so the footer's bottom corners
  inherit rounded-2xl.
- flex-shrink-0 on header and footer + min-h-0 on the body so the
  body shrinks and scrolls while the footer stays put.

One fix in PlaceFormModal.tsx:
- Save/cancel were rendered inside the scrollable body. Moved them
  into the Modal's footer slot.
2026-04-21 21:36:43 +02:00
Julien G. fccf13a7e2 Merge pull request #797 from mauriceboe/fix/786-copy-trip-todos-budget-order
fix(trips): copy todo_items and budget_category_order when duplicating a trip
2026-04-21 20:51:18 +02:00
jubnl 09431f725c feat(dashboard): add pre-copy confirmation modal showing what will and won't be copied
Introduces CopyTripDialog — a two-section modal that appears before the
copy action and lists what is carried over (days, places, budget items,
packing lists, TODOs, notes) and what is intentionally skipped
(collaborators, collab data, files, share tokens). Addresses the UX gap
raised in #786.
2026-04-21 20:45:23 +02:00
jubnl 13162c0920 fix(trips): copy todo_items and budget_category_order when duplicating a trip
Both tables were added after the original copy logic in #270 and were
silently omitted on copy. todo_items are copied with checked reset to 0
and assigned_user_id nulled; budget_category_order rows are copied verbatim.
Adds TRIP-027 regression test.

Closes #786
2026-04-21 20:38:53 +02:00
Julien G. e25b513d0b Merge pull request #793 from mauriceboe/fix/atlas-bucket-list-ui-overflow
fix(atlas): constrain bucket list width to prevent panel overflow
2026-04-21 20:28:58 +02:00
jubnl 9012bffabc fix(atlas): constrain bucket list width to prevent panel overflow
With 30+ bucket list entries the panel expanded to near-full viewport
width, elongating the Stats tab, hiding overflow entries, and covering
the Leaflet zoom controls. Measure the stats content width via
ResizeObserver and use it as maxWidth on the horizontal bucket row so
scroll activates exactly when entries exceed the stats panel width.

Also fixes the ResizeObserver test mock to use a class (matching the
IntersectionObserver pattern) so the instance methods are accessible.

Closes #787
2026-04-21 20:21:40 +02:00
jubnl 24a85b0f91 fix(reservations): clear location when accommodation place is removed
When hotel_place_id is cleared in the modal, also clear the location
field that was auto-filled from the place. Location is hidden for hotel
type so users had no way to remove the stale address after unlinking.
2026-04-21 19:54:43 +02:00
jubnl 43a503b593 fix(reservations): always update place_id when saving hotel accommodation
When clearing the accommodation place from a hotel reservation, the
update branch that runs without a place_id omitted the column from its
UPDATE statement, leaving the old place linked in day_accommodations.
Collapse the two branches into one that always writes place_id (null or value).
2026-04-21 19:51:44 +02:00
jubnl a81fe3da0a fix(reservations): clear editingReservation after successful save
When a reservation was saved, only setShowReservationModal(false) was
called. The modal's useEffect watches [reservation, isOpen, ...], so
flipping isOpen to false re-ran the effect with the stale editingReservation
(old assignment_id), resetting the form to the pre-edit state during the
closing animation. Users perceived this as the value reverting after save.

Calling setEditingReservation(null) immediately after the close mirrors
the existing onClose handler and prevents the stale-prop form reset.
2026-04-21 18:52:24 +02:00
jubnl 70ba4d5435 fix(reservations): show day date range on accommodation cards
Hotel reservations store their date range in day_accommodations rather
than on reservation_time, so the card date block never rendered. Pull
accommodation_start_day_id / accommodation_end_day_id from the SQL join
and surface them on the card.

Also apply Maurice's badge-pill pattern (day name + localized date pill)
to the day-range display, consistent with the modal day selectors.
2026-04-21 18:12:40 +02:00
jubnl 881b9d0939 chore: add troubleshooting in bug report template 2026-04-21 17:25:59 +02:00
jubnl 758de855bf docs: more common issues in troubleshooting 2026-04-21 17:22:09 +02:00
jubnl 9652874bbd fix: update dockerignore and gitignore 2026-04-21 17:02:49 +02:00
jubnl 840f5e82aa docs: update contributing wiki page 2026-04-21 16:57:38 +02:00
jubnl d59b3334dc docs(wiki): add Contributing and Development-environment to sidebar and cross-links 2026-04-21 16:52:38 +02:00
Maurice 5a64d8994e Merge pull request #785 from mauriceboe/fix/synology-cached-thumbnail-size
fix: bump synology cached thumbnail size sm->m (#782)
2026-04-21 15:28:56 +02:00
Maurice e6222894e9 fix: bump synology cached thumbnail size sm->m (#782)
fetchSynologyThumbnailBytes was still serving 240px while the
uncached streamSynologyAsset path had been bumped to 320px in
#761. Align the cached path with the streaming default.
2026-04-21 15:21:58 +02:00
Julien G. 9d48c06068 Merge pull request #783 from mauriceboe/fix/pdf-thumbnail-lat-lng
fix: pass lat/lng/name to placePhoto in PDF thumbnail fetch
2026-04-21 14:27:14 +02:00
jubnl 9f70b56a3a fix: pass lat/lng/name to placePhoto in PDF thumbnail fetch
Without these args, the Wikimedia fallback (used when no Google Maps key
is configured) silently skips the fetch because lat/lng are NaN. The plan
view (PlaceAvatar/photoService) already passes all three; this aligns the
PDF path with the same behaviour.
2026-04-21 14:21:11 +02:00
Julien G. 232dc78cc9 Merge pull request #781 from mauriceboe/fix/pdf-thumbnail-mcp-places
fix: PDF thumbnails missing for MCP-added places
2026-04-21 13:53:20 +02:00
jubnl d2c44380a4 doc: add missing pages in wiki 2026-04-21 13:44:08 +02:00
jubnl 2f9d7adf4a fix: PDF thumbnails missing for MCP-added places (osm_id)
fetchPlacePhotos only checked google_place_id, skipping places that
only have osm_id (e.g. those added via MCP). Mirror PlaceAvatar logic
by falling back to osm_id in both the filter and the photo fetch call.
2026-04-21 13:43:15 +02:00
Julien G. ba4a64241b Update Discord link in contribution guidelines 2026-04-21 13:34:52 +02:00
Maurice ee14f706c8 Merge pull request #780 from mauriceboe/feat/day-selector-date-badge
feat: show date badge on day selectors + i18n transport modal titles
2026-04-21 12:54:19 +02:00
Maurice 1cc43f63df fallback day-number badge when a day has no date
If a trip has no dates set but a day has a custom title, the
dropdown showed only the title with no context. Fall back to
'Day N' as the badge so users can still tell which day it is.
2026-04-21 12:34:45 +02:00
Maurice 3450bd59f8 feat: show date badge on day selectors + i18n transport modal titles
Day selectors in the Transport, Reservation and Hotel-Day-Range
modals only showed the renamed day title once a day had a custom
name — hiding the actual date. Added an optional badge prop to
CustomSelect, rendered as a pill next to the label, and wired the
date badge onto all affected dropdowns. FileManager day section
headers got the same pill for consistency.

Also translated transport.addTransport and transport.modalTitle.*
in all 13 non-English language files; the keys existed but still
carried the English source string.
2026-04-21 12:28:43 +02:00
Maurice 457d436cf6 Merge pull request #778 from mauriceboe/fix/public-mobile-trip-photos-filter
fix: filter [Trip Photos] container from mobile public view (#764)
2026-04-21 11:29:53 +02:00
Julien G. 1127efb9c4 Merge pull request #777 from mauriceboe/fix/issues-773-774-backups-and-trip-files
fix(backups,files): auto-backups rejected by validator; trip file download broken after cookie migration
2026-04-21 11:24:44 +02:00
Maurice 0a98d3c2e7 fix: filter [Trip Photos] container from mobile public view (#764)
MobileMapTimeline received the raw entries array, bypassing the
synthetic-container filter applied to timelineEntries. On screens
below the lg breakpoint (<1024px) the [Trip Photos] sync container
leaked back into the combined map+timeline view.
2026-04-21 11:24:07 +02:00
jubnl 5eaf7492dc fix(backups,files): auto-backups rejected by validator; trip file download broken after cookie migration
Fixes #773: isValidBackupFilename regex anchored to ^backup- rejected all
auto-backup-* filenames, causing 400 on download/restore/delete. Broadened
to ^(?:auto-)?backup-.

Fixes #774: three regressions in the trip Files tab —
- openFile import shadowed by a local function of the same name inside
  FileManager; PDF preview modal was calling the local with a URL string,
  corrupting state and crashing on the second click (mime_type read on
  undefined). Fixed by aliasing the import as openFileUrl.
- GET /:id/download used a bespoke authenticateDownload that checked only
  Bearer header and ?token= query param, ignoring the trek_session cookie.
  After the JWT-to-cookie migration the client sends cookies only, so every
  download silently 401-ed. Extended authenticateDownload to accept req and
  check cookie → Bearer → query token in priority order.
- files.download and files.openError translation keys were missing from all
  15 locale files; t() was returning the raw key as a truthy string,
  defeating the || 'Download' fallback.
2026-04-21 11:18:17 +02:00
jubnl ee31c78db8 fix(maps): null stale proxy image_url entries instead of writing unbacked proxy URLs
Migration 107 and the previous fix both wrote /api/maps/place-photo/<id>/bytes
into places.image_url without ever fetching the photo bytes. photoService
short-circuits on that URL prefix and hits /bytes directly, which 404s because
nothing is on disk.

- Add migration to null proxy image_url rows with no backing google_place_photo_meta
  entry — restores the normal fetch-and-cache flow for affected rows
- Fix the previous legacy-URL migration to null instead of rewrite, so fresh
  installs don't hit the same 404 path

Fixes #770 (follow-up)
2026-04-21 00:46:29 +02:00
jubnl edf14e2ebc test(maps): update getPlacePhoto stubs to use text() instead of json()
mapsService now reads the details response body via .text() before parsing,
so test stubs need text() rather than json().
2026-04-21 00:16:54 +02:00
jubnl 2aad8f465c fix(maps): prevent server crash when legacy Google photo URLs are stored as placeIds
Migration 107 only rewrote image_url rows matching /places/%/photos/%; URLs using
the /place-photos/ or /places/<opaque> paths survived the upgrade and were passed
verbatim to the Places API, producing a malformed request whose empty/HTML response
body threw SyntaxError before detailsRes.ok was checked. The resulting rejection was
leaked by placePhotoCache.setInFlight via an unhandled .finally() chain, triggering
Node 22's default unhandledRejection=throw and terminating the process.

- placePhotoCache: add .catch() after .finally() to prevent unhandled rejection crash
- mapsService: reject URL-shaped placeIds early; read response as text before JSON.parse
- migrations: add migration to rewrite remaining googleusercontent/places.googleapis URLs
- MapView/MapViewGL: prefer stable proxy URL form of image_url before google_place_id

Fixes #770
2026-04-21 00:13:35 +02:00
jubnl 16b81a8356 fix(bookings): preserve accommodation dates when place is unlinked or missing
- Remove NOT NULL constraint on day_accommodations.place_id (migration)
  and change ON DELETE CASCADE → SET NULL so deleting a place no longer
  cascades to the accommodation row
- Switch listAccommodations / getAccommodationWithPlace to LEFT JOIN so
  accommodations without a linked place are visible to the modal
- Relax create/update guards in reservationService to only require
  start_day_id + end_day_id, not place_id; place_id remains optional
- Client save guard now sends create_accommodation whenever FROM/TO days
  are set, regardless of whether a hotel place was selected
- Add re-hydration useEffect in ReservationModal to back-fill hotel
  fields from the accommodations prop when it arrives after modal opens
  (race between isOpen and the tripAccommodations fetch)
- Fix demo-seed TDZ crash: move db Proxy declaration before DEMO_MODE
  block so circular require in demo-reset resolves correctly
- Sidebar accommodation badge falls back to reservation title when
  place_name is null; click/cursor disabled for placeless accommodations
- listAccommodations now joins reservations to expose reservation_title
2026-04-20 23:09:05 +02:00
Maurice 5984adb2ea Merge pull request #768 from mauriceboe/fix/ui-pre-release-bugs
fix: pre-release UI bug batch (#759 #760 #761 #763 #764)
2026-04-20 22:18:53 +02:00
Maurice f8eb1915fe fix(map): render transport reservations on Mapbox GL
ReservationOverlay was Leaflet-only: react-leaflet components, L.divIcon,
panes, useMap/useMapEvents. When the user switched the planner map to
Mapbox GL, the entire feature disappeared — no polylines, no endpoint
badges, no clickable IATA labels.

Add a matching overlay for the Mapbox renderer:

- New reservationsMapbox.ts with an imperative `ReservationMapboxOverlay`
  class — mapbox-gl is imperative, so a React component wrapper would
  fight its own lifecycle every render. The manager owns one GeoJSON
  source + line layer for the arcs, one HTML `mapboxgl.Marker` per
  endpoint badge, and one per flight stats label. It cleans itself up
  when the map is rebuilt (style/token/3d toggle) or unmounted.
- Geometry helpers (great-circle arc, antimeridian split, haversine,
  tz-aware duration math, label formatting) are copied from the Leaflet
  overlay so both renderers produce the same lines. Great-circle is
  useful even on the Mapbox globe because the mercator projection mode
  still draws the short-way line, and the antimeridian split prevents
  a NYC↔Tokyo flight from wrapping halfway around the planet.
- Flights / cruises get geodesic arcs; trains / cars get straight
  lines. All four types get clickable endpoint badges with the
  matching lucide icon; only flights render the rotating mid-arc stats
  label (IATA → IATA · distance · duration) — same rule as the Leaflet
  overlay.
- The stats label's rotation is recomputed on every `render` event by
  projecting two points straddling the arc midpoint, which keeps it
  parallel to the arc as the camera rotates/zooms on the globe.
- Visibility thresholds mirror the Leaflet overlay (per-type min pixel
  distance before a line / endpoint label is worth drawing).
- MapViewGL now accepts the `reservations`, `visibleConnectionIds`,
  `showReservationStats`, `onReservationClick` props that the Leaflet
  MapView already took. `visibleConnectionIds` is honoured the same way
  — the per-booking toggle in DayPlanSidebar controls which routes
  appear, so switching the renderer doesn't lose that UX.
- Added a `mapReady` gate so the overlay can only add its source/layer
  once the map's `load` handler has attached the other trip sources;
  the gate resets on every style rebuild.
2026-04-20 22:09:19 +02:00
jubnl b556c636eb fix: tighten 401 redirect allowlist and add reset-password paths
Replaced loose includes()/startsWith() path checks with exact equality
for static routes and strict prefix matching for dynamic-token routes.
Added /forgot-password and /reset-password to the allowlist so the
password-reset flow is usable without auth. Extracted isAuthPublicPath
as a pure testable function with 14 unit tests covering regressions.
2026-04-20 21:55:15 +02:00
Maurice b20db1428d fix: pre-release UI bug batch
- Budget table column alignment: the NAME data cell had
  `display: flex` directly on the <td>, which pulled it out of the
  table-layout and desynced the column widths between data rows and the
  AddItemRow. Moved the flex wrapper into a <div> inside the cell.
  Closes #759
- Packing list: template-apply and bulk-import handlers called
  `window.location.reload()` to refresh the list, which re-rendered the
  whole trip loading screen. Both flows now merge the returned items
  into the trip store instead. Closes #760
- Journey timeline: move-up / move-down arrows were rendered on
  skeleton suggestions — skeletons are places from the linked trip and
  don't participate in sort order. Skip canReorder when
  entry.type === 'skeleton'. Closes #763
- Journey public view: the synthetic `[Trip Photos]` and `Gallery`
  entries produced by syncTripPhotos were leaking into the public
  timeline and map. The owner view already strips these in
  JourneyDetailPage — apply the same filter on JourneyPublicPage.
  Gallery photos still come from every entry so a shared gallery keeps
  showing the trip-synced photos. Closes #764
- Journey thumbnails: public gallery grid was loading the original
  asset for every tile. `photoUrl()` now takes an optional kind and the
  grid requests `thumbnail`; the lightbox still opens the original.
  Synology thumbnail default bumped from `sm` (240px) to `m` (320px)
  because `sm` looked pixelated on retina. Closes #761
2026-04-20 21:53:45 +02:00
Julien G. 4a5a59cb78 Merge pull request #766 from mauriceboe/security/audit-fixes-batch-1
security: internal audit — batch 1
2026-04-20 21:41:00 +02:00
jubnl 20bf9c2312 security: close SEC-H4/H6 gaps from second-pass review
- SEC-H6: remove conditional audience check in mcp/index.ts — audience is
  now always enforced against the mcpResource URL. Add migration to revoke
  pre-existing oauth_tokens with audience=NULL so dead rows don't linger.
- SEC-H4: validate doc.issuer against config.issuer inside discover() to
  prevent a MITM'd discovery doc from supplying a crafted expected issuer.
  verifyIdToken caller now passes config.issuer as ground truth, not
  doc.issuer.
- tests: cover three new OIDC callback failure paths (no_id_token,
  id_token_invalid, subject_mismatch) and two idempotency caps (key length
  >128 chars returns 400, body >256 KiB skips caching).
2026-04-20 21:35:30 +02:00
Maurice 9f57ab4517 security: address second-pass audit findings
- CI-C1 false positive: actions/{checkout,setup-node,upload-artifact}
  @v6 do exist (v6.0.0 releases published Oct-Dec 2025). Restore the
  @v6 refs — the earlier batch-1 commit downgraded them unnecessarily.
- Widen idempotency_keys primary key to (key, user_id, method, path)
  via new migration. Batch 1 widened the middleware lookup but left
  the table PK at (key, user_id), so `INSERT OR IGNORE` silently
  skipped the second endpoint that reused a key — the cache was
  never populated for it and a replay re-ran the handler. The
  migration rebuilds the table preserving existing rows (the old
  narrower PK guarantees no conflicts against the new looser key).
- HSTS: keep `includeSubDomains` OFF by default. Enabling it for
  every NODE_ENV=production install would break apex-domain setups
  where siblings still serve HTTP. Operators who want the stricter
  policy opt in with HSTS_INCLUDE_SUBDOMAINS=true.
- Extend the idempotency unit tests to cover the (method, path)
  dimension — same user+key on different path no longer replays.
2026-04-20 21:04:09 +02:00
Maurice 292e443dbe security: address silent-failure review findings on top of batch 1
Second-pass fixes caught by a self-review after the initial commit — each
one would have undermined a fix from the previous commit.

- mfaPolicy now goes through `verifyJwtAndLoadUser` too. Without this,
  a JWT stolen before a password reset still satisfied `require_mfa`
  until its natural 24h expiry, defeating the whole point of the
  password_version bump.
- Drop the `?? keys[0]` fallback in OIDC JWKS key selection. When the
  token carries a `kid` that is not in the current JWKS, refuse
  outright instead of picking an arbitrary key and letting the
  signature check produce a generic failure — the real failure mode
  deserves a specific error code.
- Tighten OAuth DCR custom-scheme rule so `javascript:`, `data:`,
  `vbscript:`, `file:`, `blob:`, `about:`, `chrome:` are all rejected.
  Previously the catch-all "not http/https" check admitted them; the
  authorize flow later 302s the browser to whatever is registered,
  which with a `javascript:` URI would execute attacker script on
  redirect. Also require the private-use scheme body to be reverse-DNS
  (contain a dot), matching RFC 8252 §7.1.
- permanentDeleteFile / emptyTrash only delete the trip_files row when
  the on-disk unlink actually succeeded. Previously Promise.all
  swallowed individual unlink failures and DELETE ran unconditionally,
  so a permission / ENOSPC failure would orphan bytes on disk.
- restoreFromZip also invalidates the permissions cache in the outer
  catch. If extraction threw before the DB swap even started, the
  cache wasn't stale, but belt-and-braces is cheap and guarantees no
  failed-restore path leaves stale cache behind.
2026-04-20 20:44:57 +02:00
Maurice 2d0414b4a3 security: internal audit — batch 1
Fixes the critical + high + medium findings from our internal security
review. Bundled into one PR because the changes overlap heavily (JWT
verification unifies across three call sites; backup-code hashing and
demo-email handling cross-cut several services); splitting them out
would mean redundant reviews of the same files.

Critical
- CI-C1 — .github/workflows/test.yml: restore actions/{checkout,setup-
  node,upload-artifact} to @v4. The @v6 refs don't exist, so the test
  workflow was errorring before a single test ran.
- SEC-C1 — mfaPolicy now extracts the token via extractToken() (cookie-
  first, Bearer fallback). Previously it only read Authorization, so
  every cookie-authenticated SPA session bypassed require_mfa entirely.
- SEC-C2/C4/C6 — all JWT verification paths (MCP bearer, file download,
  photo route) now go through the shared verifyJwtAndLoadUser that
  checks password_version. resetPassword additionally deletes every
  mcp_tokens row and marks outstanding oauth_tokens revoked, so a
  password reset invalidates ALL credential classes — not just the
  cookie JWT.

High
- SEC-H2 — reset email URL is built from server-side APP_URL /
  ALLOWED_ORIGINS (via existing getAppUrl()), not request headers.
  Closes the host-header-injection vector into reset links.
- SEC-H3 — OIDC findOrCreateUser wraps the invite-redemption UPDATE +
  user INSERT in a transaction. The UPDATE is the capacity check; if
  a concurrent callback takes the last slot, the whole transaction
  aborts with registration_disabled instead of double-creating users.
- SEC-H4 — new verifyIdToken() performs full JWT signature
  verification via the provider's JWKS (Node's crypto.createPublicKey
  accepts JWK directly — no extra dependency), plus iss/aud/exp
  checks. The callback also rejects the login when userinfo.sub does
  not match id_token.sub.
- SEC-H5 — OAuth DCR now validates redirect_uris against an allowlist
  of schemes: https, http-loopback, or a private custom scheme. Plain
  http://non-loopback is rejected.
- SEC-H6 — oauthService audience defaults to mcpResource when the
  `resource` parameter is missing, so tokens are always audience-bound
  to /mcp instead of being issued with audience=null.
- SEC-H7 — HSTS is enabled any time NODE_ENV=production (previously
  required FORCE_HTTPS=true), includeSubDomains defaults on and can
  be disabled with HSTS_INCLUDE_SUBDOMAINS=false.
- SEC-H8 — trek_session cookie Secure flag is also driven by
  req.secure (which Express resolves from X-Forwarded-Proto once
  trust proxy is set), so instances behind a TLS-terminating proxy
  get Secure cookies without needing FORCE_HTTPS.

Medium
- SEC-M1 — permanentDeleteFile / emptyTrash / avatar unlink now use
  fs.promises.rm with { force: true } (one async op vs the previous
  existsSync + unlinkSync pair per file).
- SEC-M2 — invalidatePermissionsCache() is called inside restoreFromZip
  so a restored DB with different permission rows is honoured
  immediately.
- SEC-M3 + C1 — idempotency store bounds the key at 128 chars, caches
  only responses ≤ 256 KiB, and scopes the lookup by (key, user_id,
  method, path) rather than (key, user_id). Same key replayed against
  a different endpoint no longer returns a stale unrelated body.
- SEC-M4 — share_tokens gets an expires_at column; new tokens default
  to 90-day TTL, expired tokens are denied at lookup. Existing tokens
  stay NULL = no expiry so already-published links don't break.
- SEC-M5 — /uploads/photos/:filename now resolves the photo to its
  trip_id and requires the share token to cover THAT trip. Previously
  any share token for any trip would unlock any photo filename.
- SEC-M6 — BLOCKED_EXTENSIONS is the single source of truth shared
  between fileService and collab uploads. The '*' allowed_file_types
  wildcard now still rejects executables/scripts.
- SEC-M7 — single DEMO_EMAILS constant (services/demo.ts) used by
  demoUploadBlock, mfaPolicy, and every demo-mode guard in
  authService. The old demoUploadBlock only matched 'demo@nomad.app'
  so the seed 'demo@trek.app' could in fact upload in demo mode.
- SEC-M8 — MFA backup codes are now bcrypt-hashed at rest
  (hashBackupCodeBcrypt). matchBackupCode accepts both bcrypt and
  legacy SHA-256 hex hashes, so existing installs keep working until
  the user regenerates codes via enableMfa.
- SEC-M9 — document the "security via UUID v4 filename" model for
  /uploads/avatars|covers|journey. Requires no code change but
  captures the decision so future reviewers don't re-flag it.
- SEC-M10 — already covered by the resetPassword revocation logic
  above: mcp_tokens DELETE + oauth_tokens UPDATE … SET revoked_at.

Performance
- PERF-H1 — new migration adds the indexes flagged in the audit:
  trips(user_id), trips(created_at DESC), photos(day_id),
  photos(place_id), reservations(day_id), share_tokens(token), plus
  conditional day_accommodations and notifications indexes depending
  on which columns are present.

Tests
- tests/integration/oidc.test.ts now mocks verifyIdToken and passes
  an id_token in the exchangeCodeForToken stub for the three flows
  that exercise a successful callback. The three remaining failures
  tests pointed out were all pre-existing (file-upload flakes +
  notificationPreferences event_types count drift), none introduced
  by this PR.
2026-04-20 20:36:52 +02:00
Maurice e612de9143 Merge pull request #757 from mauriceboe/feat/todo-due-reminders
feat(notifications): reminders for todos with upcoming due dates
2026-04-20 17:43:59 +02:00
Maurice c857d38bcd test(notifications): bump event_types count to 9 after adding todo_due 2026-04-20 17:38:25 +02:00
Maurice d7a71c0572 feat(notifications): reminders for todos with upcoming due dates
Todos already support a due_date field but nothing notifies the user
when a deadline is approaching — you'd only remember if you happened
to look at the Lists tab. This wires a reminder into the existing
notification pipeline so due-date todos behave like trip-start
reminders.

Details:
- New `todo_due` event type alongside trip_reminder; all four channels
  (in-app, email, webhook, ntfy) supported and toggleable per user in
  Settings > Notifications.
- New daily scheduler task (9 AM local TZ) queries unchecked todos
  whose due_date is within the next 3 days. Each todo gets at most
  one reminder per 24 hours, tracked via a new todo_items.reminded_at
  column (migration 116).
- If the todo has an assigned user, only that user is reminded; if
  not, every member of the trip gets the notification.
- Strings added in all 15 UI languages and for all notification
  carriers.
- Gated by app_settings.notify_todo_due (default on) so admins can
  disable it globally.
2026-04-20 17:31:25 +02:00
Julien G. 58c061e653 Merge pull request #756 from mauriceboe/fix/planner-drag-drop-jank
fix(planner): eliminate drag-and-drop jank in trip planner
2026-04-20 17:23:06 +02:00
Maurice 22d1d06d39 docs(readme): point hero GIF URL at renamed trek-media repo 2026-04-20 17:17:08 +02:00
jubnl 290f566daa fix(planner): eliminate drag-and-drop jank in trip planner
- Suppress trek-stagger animation on the day list while a drag is active
  so nth-child delays (0–320 ms) no longer re-fire on every hover change
- Replace sibling drop-indicator <div> injections with borderTop/borderBottom
  on the target row to prevent nth-child index shifts during drag
- Dedup setDragOverDayId calls in onDragOver handlers so setState is only
  invoked when the active day actually changes
- Move initTransportPositions out of getMergedItems (render path) into a
  useEffect to stop mid-drag setState cascades
2026-04-20 17:16:57 +02:00
Maurice 8ca2507050 Merge pull request #755 from mauriceboe/fix/readme-hero-gif-external
docs(readme): move hero GIF to external release asset
2026-04-20 17:11:07 +02:00
Maurice 9c666a0aaf docs(readme): move hero GIF to external release asset
Moves the 91 MB product-tour GIF out of the repo entirely. Standard
clones and CI checkouts no longer pull it — even LFS-aware clients
previously downloaded the blob on checkout, which made `git pull`
noticeably slower for everyone.

The file now lives as a release asset on a separate repo and is
referenced from README via its GitHub Fastly-backed download URL.
Removes the LFS tracking entry from .gitattributes.
2026-04-20 17:09:02 +02:00
Julien G. b3f2f7308a Merge pull request #748 from mauriceboe/docs/wiki
Docs/wiki
2026-04-20 16:50:50 +02:00
Maurice af9b31c1ff Merge pull request #754 from mauriceboe/fix/journey-gallery-picker-safari
fix(journey): repair gallery picker grid collapsing in Safari (#717)
2026-04-20 16:47:54 +02:00
jubnl d7d1493289 docs(wiki): document self-service password reset feature
Update Password-Reset.md and Login-and-Registration.md to reflect the
email-based forgot-password flow added in feat(auth): 51387b0, including
the SMTP-less console fallback, MFA gate, session invalidation, rate
limits, and security properties.
2026-04-20 16:43:53 +02:00
Maurice 54e042b736 fix(journey): repair gallery picker grid collapsing in Safari (#717)
The 'From Gallery' picker on the journey entry editor used `aspect-square`
on grid items inside an overflow-scrolling container. Safari (desktop and
iOS) collapses the computed height of aspect-ratio boxes in this layout,
which stacked every thumbnail at y=0 — making selection impossible.

Swap to the classic padding-top spacer pattern (`paddingTop: '100%'` on
the cell + absolutely positioned image) which is bulletproof across
browsers and preserves the 5/6-column grid on mobile/desktop.
2026-04-20 16:43:21 +02:00
Julien G. 0ba31847eb Merge pull request #753 from mauriceboe/dev
Dev
2026-04-20 16:36:34 +02:00
Maurice 26ab39dc21 Merge pull request #752 from mauriceboe/feat/readme-redesign
docs(readme): Apple-style redesign — animated hero, feature tiles, product tour
2026-04-20 16:27:59 +02:00
Maurice 00be0eab05 docs(readme): Apple-style redesign — animated hero, feature tiles, gallery
- Animated TREK logo (light + dark variants) via <picture> + prefers-color-scheme
- 60-second product tour GIF (91MB, 1100x619, 10fps) stored via Git LFS so
  standard clones don't pull it by default
- 9 feature tiles as composite SVG grids: 3x3 on desktop, 2x4 on mobile
- 8 fresh screenshots captured from dev.pakulat.org
- Feature details folded into a collapsible 2-column table
- Environment variables moved behind a collapsible
- Roadmap badge added next to Live Demo / Docker / Discord
- Removed redundant Community section and footer
2026-04-20 16:25:38 +02:00
Maurice ed97bb1deb Merge pull request #750 from mauriceboe/feat/password-reset
feat(auth): password reset via email with MFA + session invalidation
2026-04-20 14:16:17 +02:00
Maurice 51387b0af1 feat(auth): add email-based password reset with MFA + session invalidation
Adds /auth/forgot-password and /auth/reset-password endpoints plus two new
client pages. When SMTP is configured the user receives a branded, i18n-aware
reset email; when it isn't the reset link is logged to the server console in
a clearly-fenced block so self-hosters can relay it manually.

Security properties:
- 256-bit cryptographically-random tokens, only SHA-256 hashes stored in DB
- 60 min expiry, single-use, prior unconsumed tokens auto-invalidated
- Enumeration-safe: /forgot-password always responds {ok:true} with a minimum
  latency pad so timing doesn't leak account existence
- Per-IP rate limit (3/15min on forgot, 5/15min on reset) + per-email throttle
- If the user has MFA enabled, a valid TOTP or backup code is required at
  reset-complete time — a compromised mailbox alone cannot take over a
  2FA-protected account
- New users.password_version column + JWT "pv" claim: bumping it on reset
  invalidates every live session immediately
- Full audit-log coverage (user.password_reset_request/_success/_fail)
- Forgot-page shows a visible hint when SMTP is unconfigured

Migration 115 adds users.password_version and password_reset_tokens
(user_id, token_hash UNIQUE, expires_at, consumed_at, created_ip).
2026-04-20 14:06:42 +02:00
jubnl 1559ed12bd fix(wiki): update mapbox scopes and url 2026-04-20 10:18:44 +02:00
jubnl c1b9d11173 docs: add full wiki with 74 pages, assets, and CI workflow
Adds the complete TREK documentation wiki covering installation,
trip planning, admin panel, MCP/AI integration, addons, and operations.

Also fixes encrypt-at-rest gaps: mapbox_access_token, Synology
credentials, per-user webhook/ntfy tokens, and photo passphrases
are now rotated by migrate-encryption.ts and stored encrypted via
settingsService.
2026-04-20 10:11:53 +02:00
Julien G. 2ab8b401fb Merge pull request #747 from mauriceboe/fix/mcp-oauth-protected-resource-rfc8707
fix(mcp): RFC 9728 PRM, RFC 8707 audience binding, collab sub-feature gating, z.record Zod v4 fix
2026-04-20 08:04:23 +02:00
jubnl 49af7a8b0d fix(mcp): fix z.record() Zod v4 API compat in transport tool schemas
Zod v4 changed z.record(valueType) to z.record(keyType, valueType).
The single-arg form now sets keyType, leaving valueType as undefined.
This caused tools/list to throw 'Cannot read properties of undefined
(reading _zod)' when the SDK tried to serialize the metadata field to
JSON Schema, silently returning an error for every tools/list call and
making all MCP tools invisible in claude.ai.
2026-04-20 07:57:40 +02:00
jubnl dd90c6d424 fix(mcp): add RFC 9728 PRM, RFC 8707 audience binding, and collab sub-feature gating
Root cause: claude.ai's MCP connector (spec 2025-06-18) requires the resource server
to publish Protected Resource Metadata and return WWW-Authenticate on 401s to bind
the /mcp endpoint to its AS. Without these, it silently shows no tools after OAuth.

- Add /.well-known/oauth-protected-resource (RFC 9728) with addon gating
- Emit WWW-Authenticate: Bearer resource_metadata=... on 401/auth-failure 403s
- Open CORS (origin: *) on both .well-known/* endpoints per RFC 8414/9728
- Accept resource parameter at authorize + token endpoints (RFC 8707)
- Store audience on oauth_tokens; validate on every MCP request
- Refresh tokens inherit audience; add resource_parameter_supported to AS metadata
- DB migration: ADD COLUMN audience TEXT to oauth_tokens
- Gate collab MCP tools/resources by chat/notes/polls sub-features individually
- Invalidate MCP sessions when collab sub-features are toggled in admin
- Update test mocks and MCP.md
2026-04-20 07:34:38 +02:00
Maurice 3d887f15ab Merge pull request #746 from mauriceboe/feat/settings-sidebar-layout
feat(ui): unified sidebar layout for Settings and Admin pages
2026-04-19 21:55:10 +02:00
Maurice 82bb08e685 feat(map-settings): i18n for Mapbox GL, mobile polish
Wraps every hardcoded Mapbox/Leaflet string in MapSettingsTab with
t() and adds 18 new settings.map* keys across all 15 language files.
On mobile the provider-card subtitles are hidden, and the High
Quality Mode Experimental badge stacks above the title instead of
wrapping awkwardly next to it.
2026-04-19 21:48:26 +02:00
Maurice 4f3368502a feat(ui): introduce shared PageSidebar for Settings and Admin
Replaces the inline tab bar on SettingsPage and AdminPage with a
responsive sidebar layout (left nav on desktop, hamburger drawer on
mobile). Each tab gets a lucide-react icon for quick scanning. Both
pages drop max-w-6xl so the panel fills the viewport.
2026-04-19 21:35:31 +02:00
Julien G. 0d534f13cf Merge pull request #745 from mauriceboe/feat/mcp-journey-transport-alignment
feat(mcp): align MCP surface with current app state
2026-04-19 16:24:44 +02:00
jubnl ffa10cac65 docs(mcp): document compound tools in MCP.md 2026-04-19 16:19:36 +02:00
jubnl b85f8c5bca feat(mcp): add compound tools for common multi-step workflows
Adds three atomic compound MCP tools that collapse invariant sequential
call patterns into single operations with transaction-backed rollback:
- create_and_assign_place: create place + assign to day
- create_place_accommodation: create place + book accommodation
- create_budget_item_with_members: create budget item + set split members
2026-04-19 16:17:04 +02:00
jubnl da39b570eb feat(mcp): align MCP surface with current app state
- Add Journey addon tools (list, get, entries, contributors, suggestions,
  available trips, create/update/delete journey and entries, reorder,
  contributors CRUD, preferences, share link management)
- Add Journey resources (trek://journeys and sub-resources)
- Split transport (flight/train/car/cruise) into dedicated tools with
  endpoints[] and needs_review support; narrow reservation types to
  non-transport only
- Add airport lookup tools (search_airports, get_airport) under geo:read
- Add import_places_from_url and bulk_delete_places to places tools
- Add journey:read/write/share OAuth scopes (27 total) with translations
  across all 15 locales
- Default end_day to start_day when creating a transport (MCP + UI)
- Fix MCP.md drift: addon gates, removed files resource, corrected
  get_trip_summary description, todos under Packing addon
2026-04-19 16:03:32 +02:00
Julien G. 151950d08a Merge pull request #744 from mauriceboe/fix/health-endpoint-force-https-redirect
fix: skip FORCE_HTTPS redirect for /api/health endpoint
2026-04-19 14:31:28 +02:00
jubnl e562d7a7ec fix(test): initialize useCountUp to target immediately in jsdom to fix AdminPage stat test 2026-04-19 14:27:08 +02:00
jubnl d0383c06c3 fix: skip FORCE_HTTPS redirect for /api/health endpoint
Health probes (K8s, Docker, LB health checks) hit the endpoint over plain
HTTP from inside the cluster/container. The catch-all HTTPS redirect was
causing all probe types to fail whenever FORCE_HTTPS=true was set.

Closes #735
2026-04-19 14:10:41 +02:00
Maurice 5978eec270 allow WebAssembly in CSP for mapbox-gl 3D rendering 2026-04-19 13:42:09 +02:00
Maurice 242d1bf8d4 Merge pull request #743 from mauriceboe/fix/mapbox-csp
Allow mapbox-gl in CSP
2026-04-19 13:28:17 +02:00
Maurice 4a8260dfbc allow mapbox-gl in CSP (api, tiles, events, blob workers) 2026-04-19 13:23:50 +02:00
Maurice 076a752ee7 Merge pull request #742 from mauriceboe/fix/pwa-precache-mapbox-bundle
Raise PWA precache limit to unblock mapbox-gl build
2026-04-19 13:15:04 +02:00
Maurice 545d62c400 raise PWA precache limit so mapbox-gl bundle builds 2026-04-19 13:04:26 +02:00
Maurice f8542b4d87 Merge pull request #740 from mauriceboe/fix/journey-mobile-gallery
mapbox gl option, gps location, journey reorder + polish
2026-04-19 02:01:33 +02:00
Maurice c2fea0a26a fix tests after UI removals in journey detail
- MapSettingsTab: relax Save Map assertion to objectContaining so the new
  mapbox_* defaults don't fail a legacy exact-match expectation.
- JourneyDetailPage: skip tests tied to removed UI (right-column sidebar
  with Synced Trips / Contributors / Journey Stats, Map tab, "Live" and
  "Synced with Trips" hero badges, "Back to Journey" text link). These
  features moved into the settings dialog or were intentionally dropped
  per UX pass and no longer have DOM targets to assert against.
- FE-016: updated to use getByLabelText since the back button is now
  icon-only with aria-label.
- FE-060: drop the sticky-selector check on day headers (header is no
  longer sticky — the presence of the formatted date is sufficient).
2026-04-19 01:56:39 +02:00
Maurice 25bdf56d16 add mapbox gl option, gps location, journey reorder + polish
- Mapbox GL provider alongside Leaflet for trip and journey maps (opt-in in
  settings with token, style presets incl. 3D on satellite, quality mode,
  experimental badge).
- GPS "blue dot" with heading cone on mobile; three-state FAB (off / show /
  follow), geodesic accuracy circle, desktop-hidden since browser IP geo is
  too coarse for navigation.
- Marker drift fix: outer wrap no longer carries inline position/transform,
  so mapbox's translate keeps the pin pinned at every zoom and pitch.
- Journey map popup (mapbox-gl): Apple-Maps-style tooltip on marker
  highlight/click showing entry title + location / date subline.
- Journey feed reorder: up/down controls to the left of each entry reorder
  sort_order within a day. Server endpoint, optimistic store update, rollback
  on failure.
- Journey entry editor: desktop modal now centers over the feed column only,
  backdrop still blurs the whole page (map included).
- Scroll-sync guard on journey: marker click locks the sync so smooth-scroll
  can't steer the highlight to a neighbouring entry mid-animation.
- Misc: map top-padding aligned with hero, live/synced badges replaced by a
  compact back-button in the hero, skeleton entries no longer pollute the
  journey map, journey detail no longer shows map on mobile path when
  combined view is active.
2026-04-19 01:41:02 +02:00
Maurice d07b508a77 drop hero / inline tab-bar on mobile journey + gallery, eager map tiles
- mobile: journey and gallery views both run as chromeless overlays now.
  The hero card, backlink, stats row and inline tab-bar are hidden; the
  floating top bar (back, Journey/Gallery toggle, settings) handles
  branding for both views, and the gallery content gets a top padding
  that matches the bar so nothing is occluded.
- the journey-title pill below the tab-toggle is removed — the toggle
  itself is enough; the pill just duplicated information.
- JourneyMap tile layer: set updateWhenIdle:false and keepBuffer:4.
  Leaflet defaults to "wait for pan to settle before loading tiles" on
  mobile, which showed as a visible tile-lag when switching timeline
  cards (flyTo moves the map). Eager updates plus a wider off-screen
  ring keep the neighbouring tiles hot.
2026-04-18 22:05:19 +02:00
Maurice 9ddb2f4cd0 trim mobile labels in journey picker + guard JourneyMap flyTo
- mobile-shorten 'Alle Fotos' → 'Alle' in MemoriesPanel picker and the
  Journey ProviderPicker filter tabs (four tabs no longer wrap)
- mobile-shorten 'Datum wählen' → 'Datum' in the entry-editor DatePicker
  placeholder
- guard JourneyMap.tsx flyTo: getZoom() throws "Set map center and zoom
  first" when activeMarkerId arrives before fitBounds has set a view —
  wrap in try/catch and fall back to setView.
2026-04-18 19:29:12 +02:00
Maurice 5691149a82 Merge pull request #739 from mauriceboe/fix/journey-bugs-roel
fix: journey bugs #722-#736 (roel-de-vries batch)
2026-04-18 19:16:44 +02:00
Maurice 4974013995 fix journey bugs reported by roel-de-vries (#722-#736)
Mobile UI:
- #722 timeline carousel no longer cut off by BottomNav (uses --bottom-nav-h var)
- #723 scroll-snap-type relaxed to proximity so small swipes no longer skip entries
- #724 defensive padding-bottom fix in JourneySettingsDialog for iOS PWA
- #725 add back/settings buttons + journey title subtitle to mobile activity view
- #726 active entry re-centers after scroll settle; tap inactive card activates
  it (does not jump straight into editor)

Entry editor flow:
- #727 photo uploads queue locally until Save for existing entries too
  (previously fired upload immediately; Cancel silently kept the new photo)
- #728 Cancel/Close with unsaved changes now requires confirm (window.confirm)
- #729 linking a Gallery photo into an entry now copies the row (old MOVE
  behavior meant Remove-from-Entry also nuked the Gallery original)
- #731 addPhoto / addProviderPhoto / linkPhotoToEntry promote skeleton
  entries to concrete 'entry' type when content is added

Permissions:
- #732 updateJourney switched from canEdit to isOwner — editors can still
  edit entries and photos, just not the journey shell (title, cover, status)
- #733 Contributors list gains a per-row remove (X) control with confirm
- #734 my_role is computed server-side and returned with the journey; UI
  gates Settings/Add/Edit/Delete controls based on role
- #736 createOrUpdateJourneyShareLink + deleteJourneyShareLink now require
  isOwner (previously NO permission check at all — anyone authenticated
  could publish or unpublish a journey)

Immich upload (#730):
- migration 111: add users.immich_auto_upload (default 0)
- migration 112: seed provider_field for the toggle (idempotent, FK-safe)
- journey photo upload only mirrors to Immich when the user has opted in
- Settings UI gets a "Mirror journey photos to Immich on upload" checkbox

Test updates:
- JOURNEY-SVC-019 inverted to assert editor cannot update journey settings
- JOURNEY-SHARE-007 now passes userId (owner) to deleteJourneyShareLink
- FE-PAGE-JOURNEYDETAIL-148 inverted to assert photos stay pending until Save
- client/tests still green (2676/2676)

Also fixed en route: gallery entry title is now the literal 'Gallery' on the
wire (used to send the translated label, which broke server-side title === 'Gallery'
checks in non-English locales); confirm interpolation uses {username} single
braces matching the existing i18n runtime; Settings footer uses icon-only
delete/archive buttons on mobile so the row doesn't wrap.
2026-04-18 19:11:16 +02:00
Maurice bc192d3106 Merge pull request #738 from mauriceboe/feat/visual-features
UI polish pass: animations, transitions, shared components
2026-04-18 17:46:10 +02:00
Maurice 4db6cbef22 add Emil-style UI polish pass (animations, shared components, feel) 2026-04-18 17:39:15 +02:00
Maurice f79385cf2a Merge pull request #720 from mauriceboe/feat/pkpass-mime
Support Apple Wallet (.pkpass) file handoff
2026-04-18 12:25:02 +02:00
Maurice db2c11e4a5 support Apple Wallet pkpass files
- add "pkpass" to the default allowed upload extensions
- on download, set Content-Type: application/vnd.apple.pkpass and
  Content-Disposition: inline for .pkpass files so Safari (iOS/macOS)
  hands them off to Apple Wallet instead of downloading as a blob
2026-04-18 12:19:27 +02:00
Maurice e57c6773fc Merge pull request #719 from mauriceboe/feat/places-sidebar-polish
Places sidebar polish: filter counts, compact select mode, tooltip component
2026-04-18 11:59:13 +02:00
Maurice 4bdc032f97 de: navbar tab 'Transporte' -> 'Transport' (singular) 2026-04-18 11:48:29 +02:00
Maurice 777b68f87b fix tests for sidebar/settings refactor + weather archive fallback
- DayPlanSidebar: add aria-label to undo button, replace title with aria-label
  so tests can still locate buttons by accessible name after tooltip refactor
- tests: switch getByTitle("Add Note") to getByLabelText
- tests: find undo button via aria-label (new expand/collapse button also uses
  width:30, breaking the old style-based lookup)
- PlacesSidebar tests: loosen "All" button regex to account for count badge
- DisplaySettingsTab tests: use getByRole for Auto button (two "Auto" spans
  coexist for mobile/desktop); handle multiple English matches in lang test
- weatherService tests: past-date case now expects an archive fetch instead
  of an immediate no_forecast error
2026-04-18 11:45:19 +02:00
Maurice 66a7de09c1 dayplan toolbar polish + weather archive fallback
- weather: add archive API branch in getWeather for past dates
  (previously returned no_forecast, making the day-strip widget show "—")
- dayplan: add expand/collapse-all toggle between ICS and Undo with
  animated icon swap (ChevronsUpDown <-> ChevronsDownUp)
- dayplan: drop the trip title + date range block from the sidebar header
  (already shown in the page header), toolbar now right-aligned
2026-04-18 11:34:57 +02:00
Maurice a19ae9e653 mobile settings polish
- settings: hide color-mode icons on mobile, shorten "Automatisch" -> "Auto"
- settings: language picker as custom dropdown on mobile
- admin permissions: reset button icon-only on mobile, sized to match save
- admin places toggles: add flex-shrink-0 + row gap so switches don't collapse
- de: settings.notifications label "Benachrichtigungen" -> "Mitteilungen"
2026-04-18 11:21:08 +02:00
Maurice 38f4c9aecb refine places sidebar: filter counts, compact select UI, tooltip component
- replace "Auswählen" button with small Check↔X toggle next to category dropdown
- move bulk-action bar below search, icon-only buttons (Select all, Delete)
- filter tabs as pill buttons with per-filter count badges
- shared Tooltip component (portaled, delayed) replaces native title
- apply tooltip to select toggle, bulk actions, add note, add transport
- rename places.importFile: "Datei importieren" -> "Dateimport"
2026-04-18 11:10:33 +02:00
Julien G. 802d78b577 Merge pull request #714 from mauriceboe/feat/selective-file-import-perf
feat(import): selective GPX/KML element import and performance improvements
2026-04-18 06:15:45 +02:00
jubnl 3f61e1ca38 feat: add multi-day transport reservations with dedicated modal and route segmentation
Introduces a TransportModal for creating/editing flight, train, car, and cruise
reservations that span multiple days. Transport entries now break the map route
into disconnected segments so the polyline reflects actual travel legs.

- Add TransportModal with airport/location pickers, multi-day date range, and all transport types
- Extend DB schema with end_day_id on reservations (migration 110) and backfill from existing dates
- Refactor useRouteCalculation to emit [][][number,number] segments split at transport boundaries
- Update MapView, DayPlanSidebar, ReservationsPanel, TripPlannerPage to wire up transport flow
- Add transport i18n keys across all 15 languages
2026-04-18 06:10:33 +02:00
Julien G. 8e04deb0f5 Merge pull request #716 from mauriceboe/dev
Dev
2026-04-18 02:08:16 +02:00
Maurice 160bd02f13 Merge pull request #715 from mauriceboe/feat/per-trip-map-fit
feat(map): auto-fit planner map to trip places on load (#510)
2026-04-18 02:07:27 +02:00
Maurice 68a3036909 refactor: move airports.json out of server/data into server/assets
server/data is for runtime state (SQLite, backups, logs, tmp) — the
airports snapshot is a shipped dataset, not user data, and it being in
there forced us to poke a hole in both .dockerignore and .gitignore.
Move it to server/assets/ and drop the exceptions; service and build
script point at the new path.
2026-04-18 02:02:09 +02:00
Maurice ec4aaa628f fix(docker): include server/data/airports.json in the image
The existing 'data' entry in .dockerignore hid the committed
airports.json snapshot from the build context, so every Docker
deployment ended up without it. The airport service then logged the
"missing" warning and the autocomplete silently returned no results —
the dropdown flashed a loading spinner and disappeared. Add an
exception that keeps the SQLite DB, logs and tmp excluded but lets
the airports snapshot through.
2026-04-18 01:57:01 +02:00
Maurice 2c0894b330 fix(types): add missing map_booking_labels to Settings interface
The booking-labels toggle from the transport-routes-on-map change was
reading and writing settings.map_booking_labels without the key being
declared on the Settings type, so the store typing was inconsistent.
Adds it as an optional boolean to match the other display toggles.
2026-04-18 01:48:53 +02:00
Maurice bd2bdebc33 feat(map): auto-fit the planner map to the trip's places on load
Closes the annoyance from discussion #510 — the planner opened every
trip centered on the global default, even when the trip's places were
on the other side of the world. We already have a BoundsController
that fits the map on the current places when fitKey changes, so
nudging fitKey once per trip (after the places have loaded) gives each
trip its own starting view without any new settings or UI. If a trip
has no places with coordinates yet, the global default still applies.
2026-04-18 01:43:55 +02:00
Maurice 2857ff594c Merge pull request #713 from mauriceboe/feat/dashboard-unified-toolbar
feat(dashboard): unify desktop header with planner toolbar
2026-04-18 01:38:50 +02:00
Julien G. 4f01a10277 Merge branch 'dev' into feat/selective-file-import-perf 2026-04-18 01:32:09 +02:00
Maurice ee805369d1 test(dashboard): loosen settings-button matcher for the new toolbar
The unified toolbar gives the gear button a title attribute for a11y,
which broke the previous "no title, no text" matcher. Matching on the
lucide-settings icon plus an empty text node is enough to identify it
uniquely on this page.
2026-04-18 01:30:55 +02:00
jubnl 6a718fccea feat(import): selective GPX/KML element import and performance improvements
Add type-selector UI in the file import modal letting users choose which
GPX elements (waypoints, routes, tracks) or KML/KMZ elements (points,
paths) to import. KML LineString placemarks are now imported as path
places with route_geometry.

Performance improvements:
- Extract MemoPlaceRow with React.memo and contentVisibility:auto to cut
  unnecessary re-renders in PlacesSidebar
- Add weatherQueue to cap concurrent weather fetches at 3
- Replace sequential per-place deletes with a single bulkDelete API call
  (new DELETE /places/bulk endpoint + deletePlacesMany service)
- Memoize atlas/photo/weather service calls to avoid redundant requests
- Add multi-select mode to PlacesSidebar for bulk operations

Add large GPX/KML/KMZ fixtures for integration/perf testing and two
profiler analysis scripts under scripts/.
2026-04-18 01:28:37 +02:00
Maurice 01ed60e2d5 refactor(vacay,journey): drop redundant buttons from the new toolbar
Vacay: remove the filter-sidebar toggle from the desktop bar and shift
the breakpoint so the pre-existing mobile/tablet header (which still
has the toggle) handles everything below the lg threshold where the
sidebar is always visible anyway.

Journey: drop the desktop search toggle and inline search input from
the bar. Mobile search UI is untouched.
2026-04-18 01:16:18 +02:00
Maurice 8042db8d7a feat(vacay,journey): apply the same unified toolbar header
Wraps the Vacay and Journey desktop headers in the shared rounded
bg-tertiary bar (title + divider + subtitle, actions grouped on the
right, border and light shadow for contrast). Vacay keeps its filters
sidebar-toggle inside the bar on tablet widths; Journey keeps the
search-toggle and the primary "Create journey" action. Mobile headers
are unchanged.
2026-04-18 01:13:33 +02:00
Maurice 21649d3cf0 feat(dashboard): unify desktop header with the planner toolbar style
Brings the dashboard header in line with the Bookings/Lists/Budget/Files
toolbars: a single rounded bg-tertiary bar that groups the title, the
active/archived trip counters, and the view-toggle + widgets + new-trip
actions. Added a border and light shadow so the bar stands out against
the dashboard background in both light and dark mode. Mobile header is
untouched.
2026-04-18 01:08:02 +02:00
Maurice b9395e1e36 Merge pull request #706 from mauriceboe/dev-maurice
feat(bookings): show transport routes on map (#384, #587)
2026-04-18 00:29:54 +02:00
Maurice 10d1f8d428 test(todo): update add-task tests for toolbar button migration
The "Add new task..." button moved from the panel into the shared
toolbar and is triggered via addItemSignal. Rewrite the three affected
tests to drive that signal through a rerender instead of clicking the
removed in-panel button.
2026-04-18 00:25:06 +02:00
Maurice 0c00f8e0b3 feat(about): add monthly supporters section with 5 tiers
- Tier cards (Hostel Bunkmate through No Return Ticket) with gradient
  icons and placeholder state for empty tiers
- Animated shimmer badge and subtle radial glow behind the card
- Mobile-responsive layout, name chips show just the month on small
  screens to avoid overflow
- Copy + translations for all 15 supported languages
2026-04-18 00:22:00 +02:00
Maurice 71637a8483 fix(tests): restore packing panel inline header + update tests for ui changes
- PackingListPanel accepts inlineHeader prop (default true) to keep its
  legacy title and inline import button; ListsContainer passes
  inlineHeader={false} since the toolbar now owns those controls
- ReservationModal tests look for the renamed 'Car' button (was 'Rental Car')
- Budget total-budget test asserts against the split integer/decimal
  spans that replaced the single text node
2026-04-17 23:56:42 +02:00
Maurice 189b257254 Merge remote-tracking branch 'origin/dev' into dev-maurice
# Conflicts:
#	client/src/components/Todo/TodoListPanel.tsx
#	server/src/db/migrations.ts
2026-04-17 23:44:53 +02:00
Maurice cd2f50bc89 chore: trigger CI 2026-04-17 23:36:31 +02:00
Maurice 530550455d feat(ui): unified toolbar design + redesigned budget widgets + polish
Trip planner now has a consistent rounded toolbar across bookings, lists,
budget and files. Each panel shows title, inline filter pills (with
counts where useful) and an accent action button on the right. Moved
per-tab controls into the toolbar — lists import, todo add, budget
currency/add-category, files trash/filters — and dropped the redundant
in-panel headers.

Budget sidebar redesigned: total-budget card with indigo-ringed avatars
and coloured split bar; settlement flows as paired avatar cards;
by-category donut rebuilt in SVG with per-category gradients. Both cards
now follow dark/light mode via a widgetTheme helper.

Todo: add-new-task is a portalled modal on desktop, the add-task input
bar is gone; new SORT BY section in the sidebar; inline category
creation in the task editor.

Reservations: pending / confirmed sections remember their collapsed
state per trip (localStorage).

Misc: per-trip connections toggle moved into the day-plan sidebar,
booking endpoints fixed to show on map for trains/cruises/cars as well,
label localStorage persistence, RESMODAL test updated to the new
airport-select flow.

i18n: the new booking / map / todo / budget strings are translated into
all 15 supported languages.
2026-04-17 23:25:38 +02:00
Julien G. 9a31fcac7b Merge pull request #710 from mauriceboe/feat/photo-thumbnail-cache-686
feat(photos): 1h disk cache for remote thumbnails + fix tab-switch redundant requests
2026-04-17 21:28:42 +02:00
jubnl 677157de1d test(journey): fix getByText assertions broken by keep-mounted tab change
Tabs are now always mounted (visibility toggled via hidden class), so
the same entry title can appear in multiple tab views simultaneously.
Replace getByText with getAllByText for presence checks; scope the
FE-086 click target to the cursor-pointer container.
2026-04-17 21:02:46 +02:00
jubnl b5b1d32b31 feat(photos): add 1h disk cache for remote thumbnails and keep tabs mounted
Closes #686

- Add trekPhotoCache service: SHA1-keyed disk cache under uploads/photos/trek/,
  1h TTL, in-flight dedup map to prevent stampedes on concurrent requests
- Add migration 108: trek_photo_cache_meta table
- Hook cache into streamPhoto for Immich/Synology thumbnail path;
  originals bypass cache
- Add fetchImmichThumbnailBytes / fetchSynologyThumbnailBytes returning
  Buffer instead of piping, used by the cache layer
- Add scheduler entry (every 2h + startup sweep) to evict expired disk
  files and DB rows via sweepExpired()
- Client: convert journey tab conditional-mount to hidden-toggle so
  img elements stay in DOM across tab switches, preventing redundant
  thumbnail requests on rapid tab changes
- Expose invalidateSize() on JourneyMapHandle; call it on map tab
  activation to fix Leaflet rendering in previously-hidden container
2026-04-17 20:49:38 +02:00
jubnl ae4dfc48cc fix(pdf): add allow-scripts to iframe sandbox to suppress CSP warning 2026-04-17 20:22:31 +02:00
Julien G. 3b487519a5 Merge pull request #709 from mauriceboe/feat/system-notice-version-gate
feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate
2026-04-17 20:15:19 +02:00
Julien G. 1425c4e05b Update maxVersion explanation in system-notices.md
Clarified the explanation regarding setting maxVersion for notices.
2026-04-17 20:09:34 +02:00
Julien G. a84aedc3b4 Fix range notation for app version filtering 2026-04-17 20:07:34 +02:00
jubnl 4b7ba6cb3f feat(system-notices): apply version gates to v3 upgrade notices 2026-04-17 20:04:54 +02:00
jubnl 5952e02971 feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate
Prevents users who upgrade across multiple versions from seeing all
interim notices at once. Version bounds are evaluated server-side using
semver.coerce so prerelease builds compare as their base release.
Range is lower-inclusive, upper-exclusive: maxVersion: '4.0.0' hides
the notice once 4.0.0 ships.
2026-04-17 20:03:23 +02:00
jubnl 8cd5aa0d23 fix(synology): correct multi-album passphrase assignment and stale trek_photos
- ProviderPicker now tracks per-asset album passphrase in a Map; on confirm,
  assets are grouped by passphrase and submitted as separate batches so each
  asset receives its own album's passphrase instead of the last-selected one
- getOrCreateTrekPhoto unconditionally overwrites the stored passphrase when
  a fresh one is supplied, allowing re-adds to heal a stuck bad passphrase
- deleteTrekPhotoIfOrphan purges the trek_photos row for provider assets when
  no trip_photos or journey_photos reference it anymore; wired into
  removeTripPhoto, removeAlbumLink, and deletePhoto so remove + re-add is a
  clean slate
- Three new integration tests: SYNO-090 (passphrase overwrite), SYNO-091
  (orphan cleanup), SYNO-092 (remove + re-add restores correct passphrase)
2026-04-17 19:48:12 +02:00
Julien G. c0aa252f9a Merge pull request #708 from mauriceboe/fix/google-places-api-quota-reduction
fix(maps): reduce Google Places API quota with persistent caching and kill-switch
2026-04-17 19:33:51 +02:00
jubnl 8a58ce51c0 feat(maps): add kill switches for Google Places autocomplete and details
Add admin toggles for places_autocomplete_enabled and places_details_enabled
alongside the existing places_photos_enabled, all default ON.

- adminService: getPlacesAutocomplete/updatePlacesAutocomplete, getPlacesDetails/updatePlacesDetails
- admin routes: GET/PUT /admin/places-autocomplete, /admin/places-details
- maps routes: autocomplete returns { suggestions: [], source: 'disabled' } when off;
  details returns { place: null, disabled: true } when off
- authService: both flags included in getAppConfig() response
- authStore: placesAutocompleteEnabled + placesDetailsEnabled state and setters
- App.tsx: wire both flags from app-config on load
- AdminPage: two new toggle rows using var(--text-primary)/var(--border-primary) consistent with rest of UI
- i18n: all 15 locales (en, de, ar, br, cs, es, fr, hu, id, it, nl, pl, ru, zh, zhTw)
2026-04-17 19:28:40 +02:00
jubnl 9c2decb095 fix(maps): reduce Google Places API quota usage with persistent caching
P0 — stop the bleeding:
- Honor place.image_url in MapView and TripPlannerPage to skip redundant fetchPhoto calls
- Trim Place Details field mask (drop reviews/editorialSummary from default; new getPlaceDetailsExpanded for inspector)
- Admin toggle places_photos_enabled (default ON) to kill Google photo fetches under quota pressure; Wikimedia unaffected
- Return { photoUrl: null } instead of 204 so client handles disabled state cleanly

P1 — structural fix:
- New placePhotoCache service: persistent disk cache at uploads/photos/google/<sha1>.jpg, atomic writes, stampede dedup via in-flight Map
- Migrations 105-107: google_place_photo_meta table, place_details_cache table, backfill signed Google URLs to stable proxy URLs
- getPlacePhoto rewrites to fetch image bytes directly, store on disk, return /api/maps/place-photo/:id/bytes proxy URL
- Stable proxy URLs written to places.image_url — survive container restarts, no expiry
- New GET /api/maps/place-photo/:placeId/bytes route serving cached files with long-lived Cache-Control
- Place Details DB row cache with 7-day TTL; ?refresh=1 escape hatch
- photoService fast-path: proxy URLs bypass the mapsApi round-trip and go straight to urlToBase64

Bug fixes:
- MapView now requests base64 thumbs for places with proxy image_url (markers were showing color fallback)
- createPlaceIcon accepts /api/maps/place-photo/ URLs as interim fallback while thumb generates
- setSelectedAssignmentId ReferenceError in mobile day-detail handler (use selectAssignment)
- Remove redundant decodeURIComponent on already-decoded Express route param
- Use SHA1 hash for disk filenames to prevent coords:lat:lng pseudo-ID collisions
- Add checkSsrf guard to Wikimedia byte fetch
- Tighten migration 107 LIKE filter to avoid rewriting manually-pasted Google image URLs
- Validate enabled is boolean on PUT /admin/places-photos
- Drop aggressive iconCache.clear() on every thumb arrival

Observability:
- googleFetch() wrapper counts and debug-logs every outbound Google API call with running total
2026-04-17 19:07:39 +02:00
Maurice 5e9c8d2c43 fix(bookings): client test failures after map overlay refactor
- Make useEndpointPane tolerant when map mock lacks getPane/createPane
- Add useMapEvents to react-leaflet mock in MapView.test
- Rewrite RESMODAL-042 to use the new AirportSelect flow (airline and
  flight number only; airport codes are now saved as endpoints, not
  metadata)
2026-04-17 19:03:21 +02:00
Julien G. 39f13881c5 Merge pull request #707 from mauriceboe/fix/journey-page-bugs
fix(journey): fix issue #704 — active logic, archive, places rename, search & trip reminders
2026-04-17 17:05:43 +02:00
jubnl 3b94727c07 fix(journey): fix issue #704 — active logic, archive, places rename, search, trip reminders
- Derive journey lifecycle from linked trip dates (live/upcoming/completed/draft)
  instead of relying solely on status field; status=archived always wins
- Add Archive/Restore Journey action in journey settings dialog
- Rename cities → places end-to-end (SQL alias, TS types, stats field, all locales)
- Wire up search icon: toggles inline input, filters by title+subtitle client-side
- Fix channelConfigured check: trip reminders enabled by default since inapp is
  always available; remove channel check, controlled solely by admin setting
- Expose notify_trip_reminder toggle in Admin → Settings → Notifications
- Add trip_date_min/trip_date_max to listJourneys SQL for client-side lifecycle
- Add archived status to Journey type (server + client)
- Update all 15 locale files with new keys (search, archive, places, trip reminders)
2026-04-17 16:59:23 +02:00
Julien G. 4a5a461d25 Merge pull request #701 from mauriceboe/fix/mobile-overlay-bottom-nav
fix(mobile): account for bottom navbar in overlays and improve system notices UX
2026-04-17 15:40:57 +02:00
jubnl 1963573db4 fix(synology): use Thumbnail API with size xl for originals to avoid HEIC
Replace SYNO.Foto.Download with SYNO.Foto.Thumbnail (size=xl) for the
original kind, mirroring the Immich approach. Synology's download endpoint
returns the raw file (HEIC for iPhone photos), while the Thumbnail API
always serves a browser-compatible JPEG render.
2026-04-17 15:35:42 +02:00
jubnl 5046e1a2e0 fix(synology): wire shared-album passphrase through journey-entry add flow
Thread selectedAlbumPassphrase from ProviderPicker through onAdd →
journeyApi.addProviderPhotos → POST /entries/:entryId/provider-photos →
addProviderPhoto service → getOrCreateTrekPhoto so shared-album photos
have their passphrase encrypted and persisted on trek_photos at add-time,
enabling streamPhoto to forward it to Synology correctly (#689).
2026-04-17 15:33:05 +02:00
jubnl a1f3b4476e fix(system-notices): overhaul mobile bottom sheet UX
- Replace "Next Notice >" CTA with proper < > pager buttons
- Fix shared scroll container: each slot now scrolls independently
- Sheet uses fixed h-[85dvh] so height is consistent across all notices
- Sticky footer (pager + CTA) always anchored at bottom of each slot
- Content area vertically centered when shorter than available space
- Dismiss-drag suppressed when slot is scrolled down (pan-up to scroll back)
- Scroll position resets on navigation via per-slot refs
- Adjacent slot scroll cleared on horizontal gesture classification
- OK button navigates to next notice on non-last pages, dismisses on last
- OK button only shown when dismissible or on last notice
2026-04-17 15:06:23 +02:00
Maurice 8defc90e95 feat(bookings): show transport routes on map (#384, #587)
Adds from/to endpoints to flight/train/cruise/car reservations with
live map rendering. Flights use geodesic arcs and a curved duration +
distance badge; train/car/cruise render as straight or geodesic lines
with endpoint markers. Airports come from an embedded OurAirports
database (~3200 airports, offline-capable); train/cruise/car locations
via Nominatim. Per-trip connection toggle sits in the day plan
sidebar, persisted in localStorage. Clicking a map endpoint opens the
existing transport detail popup. New display setting toggles endpoint
labels on the map. Migration 105 adds the reservation_endpoints table
plus needs_review flag; existing flights are backfilled from their
IATA metadata on server startup.
2026-04-17 14:04:40 +02:00
jubnl b2a39a3071 Merge dev into fix/mobile-overlay-bottom-nav, resolve conflicts 2026-04-17 00:01:18 +02:00
Maurice 21511c2f68 Merge pull request #700 from mauriceboe/feat/v3-thankyou-notice
feat: v3 thank-you notice, mobile map+timeline, modal UX improvements
2026-04-16 23:51:13 +02:00
Maurice 0e5c819f7c fix: adapt tests for last-page-only dismiss and fix editor z-index
- SystemNoticeModal tests: navigate to last page before testing
  X button, ESC, and CTA dismiss (matches new last-page-only behavior)
- EntryEditor: use z-[9999] instead of portal (fixes iOS stacking
  without breaking test DOM queries)
- Pros/cons inputs: remove colored backgrounds in dark mode
2026-04-16 23:46:07 +02:00
Maurice 0f44d7d264 feat(journey): combined map+timeline view on mobile (Polarsteps-style)
Merge the separate Timeline and Map tabs into a single fullscreen
combined view on mobile (<1024px). A Leaflet map fills the background
while a horizontal snap-scroll carousel of entry cards sits at the
bottom. Scrolling the carousel auto-focuses the corresponding map
marker; tapping a marker scrolls to the card. Tapping a card opens
a new fullscreen entry view with edit/delete actions.

- New: MobileMapTimeline, MobileEntryCard, MobileEntryView components
- New: useIsMobile hook (matchMedia < 1024px)
- JourneyMap: fullScreen + paddingBottom props, focusMarker guard
- Desktop layout completely unchanged
- Public share page gets the same combined view (read-only)
- Fix: entry editor now portaled to body (iOS stacking context)
- Fix: pros/cons dark mode input backgrounds
- Fix: mood button borders in dark mode
- Fix: location icon color (neutral instead of green/indigo)
2026-04-16 23:37:09 +02:00
jubnl e078a9d9e1 fix: getAppVersion now getting 1st from environment, fallback to package.json, fallback to 0.0.0 if all failed 2026-04-16 23:36:33 +02:00
jubnl fef12b0e8b fix(mobile): account for bottom navbar in overlays and improve system notices UX
- Add paddingBottom: var(--bottom-nav-h) to all mobile overlays that were
  clipping content behind the bottom navbar: EntryEditor, SystemNoticeModal,
  JourneyPage create modal, TodoListPanel sheets, TripPlannerPage
  PlaceInspector, PackingListPanel bag modal, both PhotoLightboxes,
  FileManager viewer, and shared Modal primitive
- Replace single-notice mobile bottom sheet with a 3-slot horizontal strip
  so adjacent notices are physically present during drag
- Add live-follow swipe left/right to navigate between notices with
  spring-back when under threshold and flushSync to eliminate blink on commit
- Add live-follow swipe down to dismiss all notices with spring-back;
  backdrop tap also triggers the slide-down animation
- Normalize notice height with useLayoutEffect minHeight on strip and
  align-items: stretch so all slots are always the tallest notice height
- Pin CTA button at consistent Y across notices via flex-1 + mt-auto;
  always render invisible Not now placeholder to equalise CTA section height
- Move pager dots/counter below CTA buttons
2026-04-16 22:49:20 +02:00
Maurice df075630fb feat(system-notices): add personal thank-you notice for v3.0.0
Personal note from the creator shown as the first page in the 3.0
upgrade modal. Includes community links (Discord, Ko-fi) and a
special shout-out to jubnl. Modal UX improved: users must click
through all pages before dismissing, wider layout, enhanced
markdown rendering with styled links, signature, and HR separator.
i18n coverage across all 15 languages.
2026-04-16 22:25:03 +02:00
Julien G. bffb55d8c0 Merge pull request #699 from mauriceboe/fix/journey-gallery-lightbox-grouping
fix(journey): gallery lightbox navigates all photos, not just same-day entry
2026-04-16 21:43:07 +02:00
jubnl 5c24213b0e fix(journey): gallery lightbox navigates all photos, not just same-day entry 2026-04-16 21:35:52 +02:00
Julien G. 12a457801a Merge pull request #697 from mauriceboe/fix/journey-photo-thumbnail-cache
fix(journey): serve local file when uploading photos with Immich syncenabled
2026-04-16 21:29:59 +02:00
jubnl ae4d317dc3 fix(journey): serve local file when uploading photos with Immich sync enabled
After upload, trek_photos.provider is immediately flipped to 'immich' even
though Immich's thumbnail generation is async. streamPhoto then routed to
Immich, which returned an error for the not-yet-processed asset. Because
Cache-Control was set before the proxy attempt, the error response was cached
by the browser for 24h — breaking thumbnails until a hard refresh bypassed
the cache and Immich had finished processing.

- streamPhoto now prefers the local file_path when it exists on disk,
  regardless of provider; Immich/Synology are only used when no local
  file is available (fixes the immediate broken-thumbnail symptom)
- pipeAsset sets Cache-Control: no-store on upstream errors and uses the
  caller-supplied default only on success (prevents cache poisoning)
- streamImmichAsset no longer pre-sets Cache-Control before the proxy
- streamSynologyAsset passes the same defaultCacheControl through pipeAsset

Closes #691
2026-04-16 21:20:38 +02:00
Julien G. f7c6854059 Merge pull request #693 from mauriceboe/fix/synology-shared-albums-pagination
Fix/synology shared albums pagination
2026-04-16 21:06:28 +02:00
jubnl bdb6b01765 fix(synology): paginate all three album sources past 100 albums and tighten targetUserId type
- Extract _fetchAllSynologyAlbums helper that loops until the source is
  exhausted; listSynologyAlbums now uses it for personal, shared-out,
  and shared-with-me instead of a hard-capped single request of 100
- Make getSynologyAssetInfo targetUserId required (number, not number|undefined)
  to match every call site and eliminate an implicit any at the _requestSynologyApi
  boundary
2026-04-16 20:54:35 +02:00
jubnl 129dfabaa3 feat(synology): persist and use passphrase for shared album photo streaming (#689-4)
- syncSynologyAlbumLink now uses getAlbumLinkForSync to read the stored
  passphrase and passes it in the SYNO.Foto.Browse.Item call when present,
  falling back to album_id for links without a passphrase.
- Selection type gains optional passphrase field; addTripPhotos and
  _addTripPhoto thread it through to getOrCreateTrekPhoto.
- getOrCreateTrekPhoto accepts an optional passphrase (4th param) and
  encrypts it when inserting a new trek_photos row; backfills existing
  rows that lack a passphrase.
- streamPhoto and getPhotoInfo decrypt the stored passphrase from
  trek_photos and forward it to streamSynologyAsset / getSynologyAssetInfo
  so shared-album photos resolve correctly at access time.
- Add SYNO-054 integration test covering the passphrase sync-and-persist
  path end-to-end.
2026-04-16 20:05:18 +02:00
jubnl 8a6d1b2aaf feat(synology): merge personal, shared-out, and shared-with-me albums in listSynologyAlbums
Fire all three Synology album sources in parallel via Promise.allSettled so a
permissions failure on one source (e.g. SYNO.Foto.Sharing.Misc) never blocks
personal album display. Deduplicate by album id (last-write-wins), propagate
passphrase from shared/shared-with-me entries, and return the merged list sorted
by albumName. Extends AlbumsList type to carry optional passphrase.

Adds SYNO-027/028/029 integration tests; updates SYNO-060/061/081 to match
the new multi-source call pattern.
2026-04-16 19:56:10 +02:00
jubnl 465b78411a fix(synology): resolve pagination offset using correct size before computing page offset
The `size` → `limit` assignment was evaluated after `page * limit`, causing
the offset to be computed using the hardcoded default (100) instead of the
caller-supplied page size. Swapping the two `if` blocks ensures `limit` is
resolved from `size` first so the offset is always `(page-1) * size`.

Adds SYNO-025 and SYNO-026 integration tests that capture the raw Synology
API body and assert `offset` and `limit` are forwarded correctly.
2026-04-16 19:49:08 +02:00
Julien G. 272b32b410 Merge pull request #685 from mauriceboe/fix/hide-mobile-scrollbars
fix(ui): hide scrollbars on mobile, keep styled bars on desktop
2026-04-16 16:50:20 +02:00
jubnl 7945e752d6 fix(ui): restore scrollbar-width: thin on .scroll-container 2026-04-16 16:44:27 +02:00
jubnl 6eb3ab38fb fix(ui): hide scrollbars on mobile, keep styled bars on desktop
Scrollbars on mobile caused layout shift (content pushed left).
Hidden via media query on mobile; desktop retains thin styled scrollbars.
Also removes inline scrollbarWidth override in DayPlanSidebar that bypassed the CSS rule.
2026-04-16 16:42:36 +02:00
Julien G. c7a9210215 Merge pull request #684 from mauriceboe/fix/batch-673-674-675-678-679-680
fix(journey): batch bug fixes #673 #674 #675 #678 #679 #680
2026-04-16 16:06:52 +02:00
jubnl d5d63aa979 test(journey): fix FE-PAGE-JOURNEYDETAIL-027 flaky spinner assertion
Pre-seed the store into loading state before render instead of relying on
timing. RTL's render() flushes all microtasks via act(), so the MSW response
lands before render() returns, leaving no observable loading window.
2026-04-16 16:01:06 +02:00
jubnl 84574020f2 fix(journey): increase PDF preview button touch targets for mobile
Raises button min-height to 44px and bumps padding/font-size to meet Apple HIG
minimum touch-target guidelines on iOS PWA. Fixes #680.
2026-04-16 15:55:20 +02:00
jubnl 1b7ea2c87d fix(journey): replace window.open with srcdoc iframe overlay for PDF preview
Rewrites downloadJourneyBookPDF to render the preview in an in-page srcdoc
iframe overlay instead of calling window.open(), which Safari iOS PWA blocks
in async callbacks. Matches the existing TripPDF pattern. Fixes #679.
2026-04-16 15:54:07 +02:00
jubnl 47b7678975 fix(journey): remove backdropFilter from modal overlays to fix iOS Safari PWA white screen
backdrop-filter: blur() on position:fixed elements is a known Safari iOS
compositing failure in standalone (PWA) mode. When the GPU layer behind
a fixed overlay is uninitialized, the blur samples white instead of the
actual content, overriding the semi-transparent background and rendering
a fully white screen that requires a force-close to escape.

The JourneySettingsDialog (bottom-sheet on mobile) was most affected due
to its items-end layout, but all five modal overlays in JourneyDetailPage
had the same pattern. Removed backdropFilter from all five and bumped
opacity from 0.6 to 0.75 to maintain visual separation. Closes #678.
2026-04-16 15:45:37 +02:00
jubnl da70388f4b fix(journey): resolve Immich photos on public share by matching trek_photos.id
validateShareTokenForPhoto was querying journey_photos by jp.id but the
public page sends p.photo_id (trek_photos.id) in the URL. In a fresh
database the IDs coincidentally match, masking the bug. In production
instances with many Immich-synced photos the trek_photos autoincrement
is far ahead of journey_photos, causing a 404 for every Immich photo
on the public share page.

Fix: change the lookup to jp.photo_id = ? so validation is keyed on
trek_photos.id, which is what the client sends and what streamPhoto
needs. Updated the test helper to return trekId and added a regression
test that pre-populates trek_photos to produce diverging IDs. Closes #675.
2026-04-16 15:37:24 +02:00
jubnl 6c1a795460 fix(journey): paginate Immich picker and group photos by date
The /search route was looping up to 20 pages server-side, returning a
blob of up to 1000 photos with no hasMore flag, which prevented the
client's existing ScrollTrigger infinite scroll from ever firing.

Now the route proxies the client's page param directly to Immich and
returns a single page plus hasMore, enabling full library browsing.

The photo picker grid now groups photos by takenAt date (already
present in every asset response) with a date label above each group,
restoring the date-oriented browsing from V2. Closes #674.
2026-04-16 15:32:56 +02:00
jubnl 75d23eb6aa fix(journey): keep page mounted during in-place journey refetch
loadJourney previously set loading=true unconditionally, causing the
JourneyDetailPage guard (if loading || !current) to unmount the entire
page tree on every background refetch — entry saves, settings saves,
trip link/unlink, contributor invite, delete, and WS realtime events
all triggered the full-page spinner flash.

Now loading is only toggled on cold loads (current?.id !== id).
Warm refreshes replace current silently so the hero, sidebar, map,
and timeline stay mounted throughout. Closes #673.
2026-04-16 15:27:13 +02:00
Julien G. 0c4de72356 Merge pull request #683 from mauriceboe/feat/system-notices
Feat/system notices
2026-04-16 15:14:05 +02:00
jubnl 5e8602c50a fix(system-notices): fix FE-SN-BANNER-004 to reflect highest-priority-first array order 2026-04-16 15:08:52 +02:00
jubnl 61b8070626 fix(system-notices): coerce prerelease app version before semver comparison 2026-04-16 14:58:38 +02:00
jubnl 5caaeff67c fix: syntax 2026-04-16 14:55:35 +02:00
jubnl 92a1f9c448 fix(system-notices): reset notice store on logout so addon-gated notices show after re-login 2026-04-16 14:53:33 +02:00
jubnl 58a8e97f94 feat(system-notices): add v3-mcp notice for OAuth 2.1 upgrade
Adds a warn-severity modal notice targeting existing users who have the
MCP addon enabled. Communicates that OAuth 2.1 is now the recommended
auth method, static trek_ tokens are deprecated, and the toolset has
been significantly expanded. Priority 75 — slots between v3-journey and
v3-features in the upgrade modal sequence. Translations for all 15 languages.
2026-04-16 14:48:13 +02:00
Julien G. 815b725f87 Merge pull request #682 from mauriceboe/dev
Dev
2026-04-16 14:38:24 +02:00
Julien G. d80bbd5bed Merge branch 'feat/system-notices' into dev 2026-04-16 14:38:14 +02:00
jubnl 293506217e feat(notices): add system notice infrastructure
Server-side notice registry with per-user condition evaluation (firstLogin,
existingUserBeforeVersion, addonEnabled, dateWindow, role, custom).
Notices are sorted by priority then severity, filtered against dismissals
stored in a new user_notice_dismissals table, and served via
GET /api/system-notices/active + POST /api/system-notices/:id/dismiss.

Client renders notices through a host component that partitions by
display type (modal / banner / toast). The modal renderer supports
multi-page pagination with directional slide transitions, keyboard
navigation, and correct dismiss-all semantics on CTA / X / ESC.
Dismissals are optimistic with a single background retry.

Includes 3.0.0 upgrade notices (v3-photos, v3-journey, v3-features),
onboarding welcome modal, and full i18n coverage across 15 languages.
The /journey route is addon-gated on both client and server.

Also includes: unit + integration test suites, registry integrity test
that validates action CTA IDs against client source, and technical
documentation in docs/system-notices.md.
2026-04-16 14:36:33 +02:00
Maurice 9739542a3a Merge pull request #672 from mauriceboe/feature/uncategorized-filter
feat: add uncategorized filter to category dropdown and more
2026-04-16 00:34:28 +02:00
Maurice 9f3a88223d fix: update ReservationModal test for check-in time range fields
Use getAllByText for check-in labels since both "Check-in" and
"Check-in until" now match the /Check-in/i pattern.
2026-04-16 00:29:25 +02:00
Maurice 409a63633c feat: support check-in time ranges for hotel accommodations
- Add check_in_end column to day_accommodations (Migration 102)
- Server: create/update accommodation accepts check_in_end
- Bidirectional sync: check_in_end synced between accommodation
  and linked reservation metadata (check_in_end_time)
- DayDetailPanel: shows check-in range (e.g. "14:00 – 22:00"),
  new "Until" time picker in hotel form
- ReservationModal: new check-in-until field for hotel bookings
- ReservationsPanel: displays check-in range in metadata cells
- i18n: checkInUntil keys in all 15 languages

Closes #366
2026-04-16 00:23:00 +02:00
Maurice 125436fa87 fix: correct test matchers for list import and reservations
- PlacesSidebar: match "List Import" (actual i18n value) not "Import List"
- ReservationsPanel: use unique titles to avoid matching filter buttons
2026-04-16 00:12:06 +02:00
Maurice 975846c236 fix: update tests for naver always-on and reservations redesign
- Remove server test for naver addon disabled (addon check removed)
- Update PlacesSidebar tests: "Google List" → "Import List" (both
  providers always shown)
- Update ReservationsPanel tests: status is always a span (no toggle),
  remove click-to-toggle test, update summary test
2026-04-16 00:04:14 +02:00
Maurice 7befb7d555 feat: enable naver list import by default, remove addon toggle
- Remove addon check from naver import endpoint
- Naver import always available alongside Google list import
- Migration 101: auto-enable naver_list_import for existing installs
- Remove unused isAddonEnabled import from places route
- Remove unused useAddonStore import from PlacesSidebar
2026-04-15 23:57:09 +02:00
Maurice 099255761c feat: collab sub-feature toggles and provider icons
- Add admin toggles for individual collab sections (Chat, Notes,
  Polls, What's Next) stored in app_settings
- CollabPanel adapts layout dynamically: chat always fixed 380px,
  remaining panels share space equally
- Mobile: disabled tabs are hidden
- Add Immich and Synology Photos SVG icons to photo provider toggles
- Add Luggage icon to bag tracking sub-toggle
- API: GET/PUT /admin/collab-features endpoints
- i18n: all 15 languages updated

Closes #604
2026-04-15 23:53:16 +02:00
Maurice c8fc21b8bd fix: reservations panel mobile responsiveness
- Hide type filter pills on mobile (< md breakpoint)
- Move add button right-aligned on mobile
- Separate booking code into its own row below date/time
- Hide weekday in date on mobile for space
- Reduce padding on mobile
2026-04-15 23:26:49 +02:00
Maurice 9186b8c850 feat: redesign reservations panel with unified toolbar and responsive grid
- Unified toolbar with title, type filter pills (with count badges),
  and add button in one row
- Cards redesigned: labeled fields in rounded boxes, status/type in
  header, edit/delete actions right-aligned
- Responsive grid with max 3 columns, auto-filling full width
- Type filters persist in sessionStorage per trip
- Widen reservations tab container to match other tabs (1800px)
2026-04-15 23:21:51 +02:00
Maurice e38c5fed44 feat: add uncategorized filter option to category dropdown
Add a "No Category" option to the category filter dropdown in the
places sidebar, allowing users to filter for places without an
assigned category. The filter is synced with the map view.

Closes #607
2026-04-15 22:54:23 +02:00
Julien G. 3b069bc543 Merge pull request #671 from mauriceboe/feat/admin-default-user-settings
feat(admin): add admin-configurable default user settings
2026-04-15 22:47:08 +02:00
jubnl 618b1b8697 feat(admin): add map preview and auto-save to default user settings tab 2026-04-15 22:41:33 +02:00
jubnl e45a0efce3 feat(admin): add admin-configurable default user settings
Allow admins to set instance-wide defaults for temperature unit, color
mode, time format, route calculation, blur booking codes, and map tile
URL via a new Admin > User Defaults tab. Defaults are stored in
app_settings (prefixed default_user_setting_*) and applied at read time
as a fallback — user's own explicit values always take priority.
Translations added for all 16 supported languages.
2026-04-15 22:31:41 +02:00
Julien G. 597a5f7a1d Merge pull request #670 from mauriceboe/fix/immich-heic-rendering
fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
2026-04-15 22:07:28 +02:00
jubnl 42c216b00b fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
Raw /assets/{id}/original returns HEIC bytes which only Safari can
render natively. Switch to /assets/{id}/thumbnail?size=fullsize which
Immich transcodes to a browser-compatible format.

Closes #668
2026-04-15 22:02:48 +02:00
jubnl f3751ab9aa ci: manual trigger for prerelease 2026-04-15 21:35:53 +02:00
jubnl 9e8d101d63 fix(ntfy): improve admin ntfy UX and add clear token button
- Add missing admin.ntfy.hint translation key in all 15 languages
- Add admin ntfy server hint clarifying it is the default for users
- Expose admin_ntfy_server via PreferencesMatrix so user settings
  placeholder reflects the admin-configured default
- Add clear token button to admin ntfy panel (same pattern as user settings)
- Extract common.clear from settings.ntfyUrl.clearToken across all 15 languages
2026-04-15 20:23:31 +02:00
Julien G. 5656731850 Merge pull request #669 from mauriceboe/feat/ntfy-notification-channel
feat(notifications): add ntfy as a first-class notification channel
2026-04-15 14:13:18 +02:00
jubnl 7c4ac70db3 feat(i18n): translate ntfy notification strings into 14 languages
Properly translate all ntfy-related UI strings added in the previous
commit for ar, br, cs, de, es, fr, hu, id, it, nl, pl, ru, zh, zhTw.
Product name 'Ntfy' and placeholder values kept as-is.
2026-04-15 14:08:04 +02:00
jubnl bfe84b3016 feat(notifications): add ntfy as a first-class notification channel
Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
2026-04-15 13:59:25 +02:00
Julien G. f349e567f8 Merge pull request #665 from mauriceboe/feat/indonesian-translation
Feat/indonesian translation
2026-04-15 08:17:55 +02:00
jubnl ff434f4515 fix: discord links in tests 2026-04-15 08:12:22 +02:00
jubnl 0c2e0cad5c feat(i18n): complete Indonesian translation with full parity to en.ts
- Translate all 1941 keys to Bahasa Indonesia (up from ~426)
- Add 437 keys missing since PR was opened (journey.*, oauth.scope.*,
  dashboard.mobile.*, settings.oauth.*, admin.oauthSessions.*, etc.)
- Remove 2 stale keys superseded by unified file-import flow
- Fix duplicate packing.assignUser entry
- Rename const en → const id, update export default
- Update SUPPORTED_LANGUAGES length assertion in i18n unit test (14→15)
2026-04-15 08:05:04 +02:00
Julien G. 326f9c0823 Merge pull request #664 from mauriceboe/main
Align dev
2026-04-15 07:38:11 +02:00
github-actions[bot] 6df5edfbdb chore: bump version to 2.9.14 [skip ci] 2026-04-15 05:33:46 +00:00
jubnl 5023406717 Update discord link to a permanent link 2026-04-15 07:33:26 +02:00
Julien G. 5be805910c Update Discord link in README.md 2026-04-15 07:29:06 +02:00
jubnl 191d59166c Merge remote-tracking branch 'origin/dev' into feat/indonesian-translation 2026-04-15 06:28:35 +02:00
Julien G. b0f3440221 Update Discord link in README.md 2026-04-13 23:29:43 +02:00
Julien G. 707b3f227c Update discord link 2026-04-13 23:27:09 +02:00
xenocent a4727c4c53 docs: add Indonesian to supported languages 2026-04-11 15:35:08 +07:00
xenocent 577f2b05ca feat(i18n): add Indonesian translation 2026-04-11 15:26:16 +07:00
790 changed files with 157123 additions and 51354 deletions
+5
View File
@@ -2,6 +2,7 @@ node_modules
client/node_modules
server/node_modules
client/dist
shared/dist
data
uploads
.git
@@ -30,3 +31,7 @@ sonar-project.properties
server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
wiki/
scripts/
charts/
-2
View File
@@ -1,6 +1,5 @@
# Normalize line endings to LF on commit
* text=auto eol=lf
# Explicitly enforce LF for source files
*.ts text eol=lf
*.tsx text eol=lf
@@ -14,7 +13,6 @@
*.yaml text eol=lf
*.py text eol=lf
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.jpg binary
+3
View File
@@ -12,6 +12,8 @@ body:
required: true
- label: I am running the latest available version of TREK
required: true
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input
id: version
@@ -60,6 +62,7 @@ body:
- Docker (standalone)
- Kubernetes / Helm
- Unraid template
- Proxmox Community Script
- Sources
- Other
validations:
+1 -1
View File
@@ -15,7 +15,7 @@
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main`
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed
@@ -26,9 +26,36 @@ jobs:
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) {
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
if (hasBypass) continue;
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
// Wiki-only PRs are exempt — clear label and skip
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
-5
View File
@@ -1,11 +1,6 @@
name: Build & Push Docker Image (Prerelease)
on:
push:
branches: [dev]
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
bump:
+8 -4
View File
@@ -6,6 +6,11 @@ on:
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch:
inputs:
bump:
@@ -97,16 +102,15 @@ jobs:
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
@@ -21,6 +21,39 @@ jobs:
const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number;
// bypass-branch-check label skips all enforcement
if (labels.includes('bypass-branch-check')) {
console.log('bypass-branch-check label present, skipping enforcement.');
return;
}
// Wiki-only PRs are exempt from branch enforcement
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
console.log('All changed files are under wiki/ — skipping enforcement.');
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
+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
+26
View File
@@ -0,0 +1,26 @@
name: Deploy Wiki
on:
push:
branches: [main]
paths:
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
+9 -2
View File
@@ -3,6 +3,10 @@ node_modules/
# Build output
client/dist/
server/dist/
shared/dist/
server/public/*
!server/public/.gitkeep
# Generated PWA icons (built from SVG via prebuild)
client/public/icons/*.png
@@ -16,7 +20,7 @@ client/public/icons/*.png
*.sqlite-wal
# User data
server/data/
server/data/*
server/uploads/
# Environment
@@ -58,4 +62,7 @@ coverage
*.tgz
.scannerwork
test-data
test-data
.run
.full-review
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+524
View File
@@ -0,0 +1,524 @@
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
# TREK 3.0.0
<video src="https://github.com/mauriceboe/trek-media/raw/main/.github/assets/TREK1.mp4" controls width="100%"></video>
> **The biggest TREK release to date.** A new Journey addon turns your trips into rich travel journals. Mapbox GL joins Leaflet as a first-class renderer. MCP gets a full OAuth 2.1 authorization server. Offline-first PWA, self-service password reset, and a dashboard redesigned from the ground up. Fifteen languages, top to bottom.
---
## Breaking Changes
### Photos moved from Trip Planner to Journey
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
**What this means for you:**
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
### New Immich API Key Permissions Required
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
**Previous versions required:**
| Permission | Used for |
|---|---|
| `user.read` | Connection test |
| `asset.read` | Browse photos by date, search |
| `asset.view` | Stream thumbnails |
| `asset.download` | Stream originals |
| `album.read` | List and browse albums |
| `timeline.read` | Browse timeline buckets |
**New in 3.0.0 — additionally required:**
| Permission | Used for |
|---|---|
| `asset.upload` | Sync uploaded Journey photos to Immich |
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
### OIDC_ONLY deprecated
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
---
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
## Journey Addon — Travel Journal
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
### Core
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
- **Entry reorder** — move-up / move-down arrows on each entry (desktop), skipped on skeleton suggestions
- **Hide skeletons toggle** — per-contributor setting to focus on the written entries only
### Photos
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
- **EXIF metadata** — displayed in lightbox for Immich photos
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
- **Safari gallery picker fix** — repaired grid layout collapse on Safari (#717)
### Sharing & Export
- **Public share links** — token-based access with language picker, no login required
- **Public photo proxy** — validates share token instead of auth for photo streaming
- **Thumbnail size in public gallery** — grid loads thumbnails instead of originals, lightbox keeps originals (cuts bandwidth on shared links significantly)
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
### Collaboration
- **Contributors** — invite users as editors or viewers
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
- **Cover image** — upload or pick from journey photos
### Frontend
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
- **JourneyPublicPage** — public share view with language picker and read-only timeline
---
## Mapbox GL as a First-Class Renderer
Leaflet gets a sibling. Users can now switch the trip planner map to **Mapbox GL JS** for a proper 3D globe, terrain, and 3D buildings.
- **Settings toggle** — choose between Leaflet and Mapbox GL in Settings > Map
- **Globe projection** — smooth rotating globe when zoomed out, mercator when zoomed in
- **3D terrain and buildings** — enabled on Standard and Satellite styles, with custom 3D buildings in dark/light mode
- **Trip route, GPX geometries, place markers** — full feature parity with the Leaflet renderer
- **Transport reservations overlay** — great-circle arcs for flights/cruises, straight lines for trains/cars, clickable endpoint badges with IATA codes, rotating mid-arc stats label for flights. Honours the per-booking "show route" toggle in DayPlanSidebar
- **Auto-fit on load** — planner map zooms to the trip's places on initial render
- **Booking route label toggle** — separate setting to hide IATA labels on endpoint markers
- **Infrastructure** — WebAssembly allowed in CSP for Mapbox GL's 3D engine, PWA precache limit raised so the mapbox-gl bundle builds, Mapbox endpoints allowed in `connect-src` / `img-src`
---
## MCP: OAuth 2.1 & Granular Scopes
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at `POST /oauth/register`, with strict redirect_uri validation (HTTPS / loopback / reverse-DNS private-use schemes only; rejects `javascript:` / `data:` / `file:` / etc.)
- **RFC 9728 Protected Resource Metadata** — `/.well-known/oauth-protected-resource` exposes the MCP endpoint's auth requirements for client auto-discovery
- **RFC 8707 audience binding** — tokens are audience-bound to `<app_url>/mcp` by default and validated on every MCP request
- **Consent screen** — user-facing scope selection with grouped permission display
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
- **Per-client rate limiting** — configurable rate limits per OAuth client
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
- **Compound tools** — single-call multi-step workflows (e.g. create day with places in one tool call, fetch full trip context) to reduce MCP round-trips
- **Surface alignment** — MCP tool schemas and responses kept in sync with the current app state (fewer drifted fields, correct enum sets)
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
- **Collab sub-feature gating** — MCP tools for chat/notes/polls respect the admin-level collab sub-feature toggles
---
## Self-Service Password Reset
Users can now reset their own password without admin intervention.
- **Email-based flow** — `/forgot-password` issues a single-use reset token delivered via SMTP (or logged to the server console if SMTP is not configured)
- **MFA-aware** — if the user has MFA enabled, the reset endpoint additionally verifies a TOTP code or backup code before rotating the password
- **Session invalidation** — resetting the password bumps `users.password_version`, which kicks every existing JWT, MCP static token, and OAuth bearer token for that user out in one shot
- **Server-side URL building** — the reset link is built from `APP_URL` / `ALLOWED_ORIGINS`, not from request headers, so a spoofed `Host` / `Origin` cannot redirect the link to an attacker-controlled domain
- **Rate limiting + audit** — per-IP rate limit on `/forgot-password`, all requests audited (including "no such user" so abuse is visible)
---
## Dashboard Redesign
The dashboard has been rebuilt with a mobile-first design language.
### Mobile
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
### Desktop
- **Unified header toolbar** — the dashboard, planner, vacay, and journey now share the same toolbar style
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
### Both
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
- **Dark mode** — full dark mode support across all new components
- **Shared PageSidebar** — Settings and Admin pages share a single sidebar component for layout consistency
---
## PWA Offline Mode
TREK now works offline as a Progressive Web App with full data synchronization.
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
- **Offline trip planner** — full planner functionality with cached data
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
- **Idempotency keys** — prevents duplicate mutations on replay, scoped by `(key, user_id, method, path)` so the same key on different endpoints can't leak cached bodies
- **Offline document downloads** — document downloads work from the PWA cache when the network is unavailable
---
## Transport Reservations: Multi-Day + Map Visualization
- **Multi-day transport reservations** — flights, trains, cruises, car rentals can span multiple days with a dedicated modal and automatic route segmentation across the affected days (#384, #587)
- **Map visualization** — transport endpoints render on both Leaflet and Mapbox GL maps as clickable badges with IATA codes, great-circle arcs for flights/cruises, straight lines for trains/cars, and a rotating mid-arc stats label (IATA → IATA · distance · duration) on flights
- **Per-booking route toggle** — each booking in DayPlanSidebar has a "Show booking routes" button; connections only render when toggled on
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new `check_in_end` field (#366)
- **Cascaded delete** — deleting a reservation now cleans up related budget items, file links, and trip_items
---
## Reservations Redesign
The reservations panel has been completely redesigned with a modern, unified layout.
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
---
## Apple Wallet pkpass Support
- **.pkpass MIME type** — server correctly serves `application/vnd.apple.pkpass` with the right Content-Type
- **Upload + download** — .pkpass files can be attached to bookings or places and opened directly in Apple Wallet on iOS
---
## Todo Due-Date Reminders
- **Scheduler** — a new background scheduler scans todos with upcoming due dates and sends one reminder per item (default lead: 3 days)
- **No spam** — `todo_items.reminded_at` prevents re-sending a reminder for the same item on subsequent scheduler runs
- **Notification channel aware** — reminders respect the user's notification channel preferences (email, webhook, ntfy)
---
## Collab Sub-Feature Toggles
Individual collab sections can now be toggled on/off from the admin addons page (#604).
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
- **Mobile** — disabled tabs are hidden from the tab bar
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
---
## Place Import: KMZ/KML + Naver Maps + Selective GPX
Three ways to import places into your trips.
### KMZ/KML Import
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
### Naver Maps List Import
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
- **Pagination support** — handles large Naver Maps lists with automatic pagination
### Selective GPX/KML Element Import
- **Pick what to import** — import modal now lets you choose individual waypoints / tracks / folders instead of an all-or-nothing dump
- **Performance** — larger files (thousands of points) parse and render without freezing the UI
---
## Search Autocomplete
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
- **Google Places API** — primary autocomplete provider with location bias
- **Nominatim fallback** — free fallback when Google API key is not configured
- **Bounding box bias** — search results biased to the current map viewport
---
## ntfy Notification Channel
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
- **Full i18n** — ntfy strings translated in all 15 languages
---
## Login & Language
- **Language dropdown on login page** — users can select their preferred language before logging in
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
---
## Granular Auth Toggles
- **OIDC_ONLY replaced** — split into `DISABLE_LOCAL_LOGIN`, `DISABLE_LOCAL_REGISTRATION`, and `DISABLE_PASSWORD_CHANGE` for fine-grained control over authentication methods
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
---
## Synology Photos: OTP, SSL Skip & Session Management
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
- **Skip SSL verification** — toggle for self-signed certificates
- **Device ID persistence** — prevents repeated 2FA prompts
- **Session-cleared notification** — routed through unified notification system
- **Provider URL hint** — contextual help text for Synology URL format
- **Thumbnail size bump** — default thumbnail size raised from `sm` (240 px) to `m` (320 px) so grids no longer look pixelated on retina
- **Passphrase support** — shared-album links with passphrases work from the browse UI (#689)
---
## Atlas Improvements
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
---
## i18n: Full 15-Language Coverage
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
- **Comprehensive audit** — every key translated natively, no English fallbacks
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
- **Mapbox GL settings** — localized labels for renderer toggle, style picker, 3D / quality switches
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
---
## Vacay Improvements
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
- **Holiday overlap** — vacations can now be placed on public holidays
- **Today marker** — visual indicator for the current day in the calendar
- **Unified toolbar** — same header style as planner/dashboard/journey
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
---
## iCal Export Improvements
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
---
## Budget Improvements
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
- **Category legend redesign** — prevents overflow on small screens (#564)
- **Comma decimal support** — pasting numbers with comma separators works correctly
- **Table alignment fix** — budget data rows and the "New Entry" row now share column widths (#759)
---
## Packing List Improvements
- **Bulk import + template apply without full reload** — new items appear in place instead of triggering the trip loading screen (#760)
- **Reservation link cleanup** — packing items linked to deleted reservations stay in the list without the dangling reference
- **Bag tracking** — keep track of which items live in which bag, with optional weight tracking and per-bag totals
---
## Planner & UX Improvements
- **Emil-style polish pass** — consistent transitions/animations across cards, hover states, and drawer sheets; shared components for toolbars and section headers
- **Planner drag-and-drop jank fix** — dragging places across days is smooth again on long trips
- **Unified toolbar header** — dashboard, planner, vacay, and journey share a single toolbar style for visual consistency
- **Places sidebar polish** — filter counts, compact select UI, tooltip component, "No Category" / "Uncategorized" filter (#607)
- **Dayplan toolbar polish** — cleaner alignment, weather archive fallback for past trips
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
- **File download button** — all file views now include a download button
- **Note modal** — no longer closes on outside click (#480)
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
- **Packing list menu** — no longer cut off by overflow (#557)
- **Trip date change** — preserving day content when date range changes
- **PDF export** — render restaurant, event, tour, and other reservation types
---
## Admin Panel Improvements
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
- **Naver List Import** — now always enabled, removed from addon toggles
- **Shared PageSidebar** — admin pages use the same sidebar layout as Settings
---
## Mobile Improvements
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes, drop hero / inline tab-bar, eager map tiles, trimmed picker labels
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
- **Bottom nav dark mode** — consistent dark mode styling
- **Safe area support** — proper insets for iOS PWA
---
## Documentation & Wiki
- **Full GitHub Wiki** — 74 pages covering setup, deployment, addon docs, troubleshooting, API reference, and MCP
- **CI sync workflow** — `./wiki/**` in the main repo is auto-synced to the GitHub Wiki on push to `main`
- **README redesign** — Apple-style hero with animated video, feature tiles, and a screenshot gallery; hero video hosted externally so the repo stays lightweight
- **MCP compound tools doc** — `MCP.md` documents the compound / multi-step tools
---
## Security
Fifth-pass internal audit. Critical + High + Medium findings addressed in one bundled PR:
- **JWT password_version gate** — a single `verifyJwtAndLoadUser` helper is now used by every auth surface (web session, MCP bearer, file download token, photo route, MFA policy). A password reset bumps `password_version` and invalidates every outstanding session/token for the user in one shot.
- **MFA policy via cookie** — `require_mfa` now applies to cookie-authenticated SPA sessions too (previously only the `Authorization` header was checked, so the whole SPA bypassed it).
- **OIDC id_token verification** — full JWKS-based signature verification (iss, aud, exp, nbf) plus `userinfo.sub == id_token.sub` cross-check. `kid` match is strict — no fallback to an arbitrary key.
- **OIDC invite redemption** — invite-token increment and user INSERT run in a single `db.transaction`; concurrent callbacks cannot double-redeem a single-use invite.
- **OAuth 2.1 DCR** — redirect_uri allowlist rejects `javascript:` / `data:` / `vbscript:` / `file:` / `blob:` / `about:` / `chrome:` and requires private-use schemes to be reverse-DNS (RFC 8252 §7.1).
- **OAuth audience binding** — `audience` defaults to the MCP endpoint when no `resource` parameter is sent, so new tokens always carry the correct audience claim.
- **HSTS on in production** — `NODE_ENV=production` is enough to enable HSTS (previously required `FORCE_HTTPS=true`). `includeSubDomains` stays off by default to avoid breaking apex-domain setups; opt in with `HSTS_INCLUDE_SUBDOMAINS=true`.
- **Cookie Secure behind proxies** — `trek_session` Secure flag is now derived from `req.secure` (Express's `trust proxy`-aware field), so instances behind Traefik / Caddy / Cloudflare Tunnel get Secure cookies without `FORCE_HTTPS`.
- **Share-token expiry** — public share tokens default to 90-day TTL. Existing tokens stay NULL (no expiry) so already-distributed links keep working.
- **Photo route scoping** — share tokens can only unlock photos that belong to the same trip as the token.
- **Bcrypt MFA backup codes** — backup codes are now bcrypt-hashed at rest. Legacy SHA-256 codes keep working until the user regenerates.
- **Demo-mode guards** — single `DEMO_EMAILS` registry fixes the drift where `demoUploadBlock` only matched the pre-rename `demo@nomad.app` string.
- **Filesystem safety** — `permanentDeleteFile` / `emptyTrash` / avatar cleanup use async `fs.promises.rm({ force: true })` and only drop the DB row when the on-disk unlink actually succeeded.
- **Idempotency store hardening** — key length capped at 128 chars, response bodies over 256 KiB not cached, primary key widened to `(key, user_id, method, path)` so the same key on a different endpoint does not replay an unrelated response.
- **Permissions cache invalidation** — `restoreFromZip` now drops the permissions cache after a DB swap.
- **Reset-URL source** — password-reset email URL is built from server-side `APP_URL` / `ALLOWED_ORIGINS`, never from request headers.
- **Critical DB indexes** — added `trips(user_id)`, `trips(created_at DESC)`, `photos(day_id/place_id)`, `reservations(day_id)`, `share_tokens(token)` and conditional `day_accommodations` / `notifications` indexes.
Upstream CVEs patched:
- **hono** 4.12.9 to 4.12.12 — directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), IP restriction bypass (CVE-2026-39409)
- **@hono/node-server** 1.19.11 to 1.19.13 — directory traversal (CVE-2026-39406)
- **nodemailer** 8.0.4 to 8.0.5 — CRLF injection
---
## Bug Fixes
- Fixed OIDC-only mode login/logout loop (#491)
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
- Fixed booking date handling and file auth bugs
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
- Fixed streaming response end on client disconnect during asset pipe
- Fixed per-day transport positions for multi-day reservations
- Fixed stale budget category reset when category no longer exists
- Fixed trip redirect to plan tab when active tab addon is disabled
- Fixed reservation price/budget field visibility when budget addon disabled
- Fixed HEIC photo rendering on non-Safari browsers
- Fixed CSP path matching for paths ending in /
- Fixed avatar URLs in notifications, admin panel, and budget
- Fixed budget member avatars lost after updating item fields
- Fixed budget table column alignment broken by `display: flex` on `<td>` (#759)
- Fixed collab notes line break preservation (#608)
- Fixed weather archive date handling for future trips (#599)
- Fixed duplicate skeleton entries for multi-day places (#606)
- Fixed ghost Gallery / `[Trip Photos]` entries in journal timeline and public share (#764)
- Fixed journey reorder arrows rendering on skeleton suggestions (#763)
- Fixed journey map OSM tile warning (#627)
- Fixed journey gallery picker grid collapse on Safari (#717)
- Fixed content divider placement in journal entries (#624)
- Fixed local photos wrong provider label (#625)
- Fixed Synology pagination and album scroll leak (#644)
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
- Fixed Nominatim User-Agent and error diagnostics
- Fixed map tooltips, journey creation, and contributor avatars
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
- Fixed stale accommodation_id on reservation update (#522)
- Fixed hardcoded Immich in toast — now uses provider_name
- Fixed MCP safeBroadcast recursive call bug
- Fixed MCP Zod v4 `z.record()` API compatibility in transport tool schemas
- Fixed Vite module preload polyfill CSP inline script violation
- Fixed PWA offline session redirect and file download auth (#505, #541)
- Fixed `FORCE_HTTPS` redirect applying to `/api/health`, breaking container health-checks
- Fixed journey bugs reported by @roel-de-vries (#722#736)
---
## Infrastructure
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
- **Helm chart** — moved to `charts/trek/`, published via helm-publisher action to `gh-pages`, `appVersion` used as default image tag
- **Docker** — workflow improvements, tag management cleanup, `server/data/airports.json` properly included in image after assets refactor
- **CI** — contributor workflow automation, `npm audit` removal from install steps, manual trigger for prerelease, client test job added alongside server tests with split coverage artifacts
---
## Test Coverage
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
- **Journey** — 89.5% new code coverage
---
## Contributors
Thanks to everyone who contributed to this release:
- @mauriceboe
- @jubnl
- @gravitysc
- @luojiyin1987
- @marco783
- @isaiastavares
- @tiquis0290
- @xenocent
- @gfrcsd
- @roel-de-vries
---
## Stats
| Metric | Value |
|--------|-------|
| Commits | 500+ |
| Merged PRs | 130+ |
| Files changed | 700+ |
| Lines added | 120,000+ |
| Contributors | 12+ |
---
## Upgrading
```bash
docker pull mauriceboe/trek:3.0.0
docker compose up -d
```
Migrations run automatically on startup. No manual steps required.
**Checklist:**
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
3. Enable the Journey addon in Settings > Addons to start using the travel journal
4. Try the Mapbox GL renderer in Settings > Map if you want 3D terrain and a proper globe view (requires a free Mapbox access token)
+405
View File
@@ -0,0 +1,405 @@
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
# TREK 3.0.0
> **This is the biggest TREK release to date.** Journey turns your trips into rich travel journals. MCP gets full OAuth 2.1 security. The dashboard has been redesigned for mobile-first. And every corner of the app now speaks 15 languages natively.
---
## Breaking Changes
### Photos moved from Trip Planner to Journey
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
**What this means for you:**
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
### New Immich API Key Permissions Required
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
**Previous versions required:**
| Permission | Used for |
|---|---|
| `user.read` | Connection test |
| `asset.read` | Browse photos by date, search |
| `asset.view` | Stream thumbnails |
| `asset.download` | Stream originals |
| `album.read` | List and browse albums |
| `timeline.read` | Browse timeline buckets |
**New in 3.0.0 — additionally required:**
| Permission | Used for |
|---|---|
| `asset.upload` | Sync uploaded Journey photos to Immich |
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
### OIDC_ONLY deprecated
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
---
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
## Journey Addon — Travel Journal
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
### Core
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
### Photos
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
- **EXIF metadata** — displayed in lightbox for Immich photos
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
### Sharing & Export
- **Public share links** — token-based access with language picker, no login required
- **Public photo proxy** — validates share token instead of auth for photo streaming
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
### Collaboration
- **Contributors** — invite users as editors or viewers
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
- **Cover image** — upload or pick from journey photos
### Frontend
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
- **JourneyPublicPage** — public share view with language picker and read-only timeline
---
## MCP: OAuth 2.1 & Granular Scopes
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at POST /oauth/register for browser-initiated and public clients
- **Consent screen** — user-facing scope selection with grouped permission display
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
- **Per-client rate limiting** — configurable rate limits per OAuth client
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
- **Security hardening** — Critical + High + Medium findings addressed (token storage, PKCE enforcement, scope validation)
---
## Dashboard Redesign
The dashboard has been rebuilt with a mobile-first design language.
### Mobile
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
### Desktop
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
### Both
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
- **Dark mode** — full dark mode support across all new components
---
## PWA Offline Mode
TREK now works offline as a Progressive Web App with full data synchronization.
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
- **Offline trip planner** — full planner functionality with cached data
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
- **Idempotency keys** — prevents duplicate mutations on replay (Migration 100)
---
## Reservations Redesign
The reservations panel has been completely redesigned with a modern, unified layout.
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new check_in_end field (#366)
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
---
## Collab Sub-Feature Toggles
Individual collab sections can now be toggled on/off from the admin addons page (#604).
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
- **Mobile** — disabled tabs are hidden from the tab bar
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
---
## Place Import: KMZ/KML & Naver Maps
Two new ways to import places into your trips.
### KMZ/KML Import
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
### Naver Maps List Import
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
- **Pagination support** — handles large Naver Maps lists with automatic pagination
---
## Search Autocomplete
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
- **Google Places API** — primary autocomplete provider with location bias
- **Nominatim fallback** — free fallback when Google API key is not configured
- **Bounding box bias** — search results biased to the current map viewport
---
## ntfy Notification Channel
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
- **Full i18n** — ntfy strings translated in all 15 languages
---
## Login & Language
- **Language dropdown on login page** — users can select their preferred language before logging in
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
---
## Granular Auth Toggles
- **OIDC_ONLY replaced** — split into DISABLE_LOCAL_LOGIN, DISABLE_LOCAL_REGISTRATION, and DISABLE_PASSWORD_CHANGE for fine-grained control over authentication methods
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
---
## Synology Photos: OTP, SSL Skip & Session Management
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
- **Skip SSL verification** — toggle for self-signed certificates
- **Device ID persistence** — prevents repeated 2FA prompts
- **Session-cleared notification** — routed through unified notification system
- **Provider URL hint** — contextual help text for Synology URL format
---
## Atlas Improvements
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
---
## i18n: Full 15-Language Coverage
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
- **Comprehensive audit** — every key translated natively, no English fallbacks
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
---
## Vacay Improvements
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
- **Holiday overlap** — vacations can now be placed on public holidays
- **Today marker** — visual indicator for the current day in the calendar
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
---
## iCal Export Improvements
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
---
## Budget Improvements
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
- **Category legend redesign** — prevents overflow on small screens (#564)
- **Comma decimal support** — pasting numbers with comma separators works correctly
---
## Planner & UX Improvements
- **Collapsible day detail panel** — day detail panel can be collapsed/expanded in the planner
- **Uncategorized filter** — "No Category" option in category dropdown to find places without a category (#607)
- **Map multi-category filter** — filter syncs with map view for uncategorized places
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
- **File download button** — all file views now include a download button
- **Note modal** — no longer closes on outside click (#480)
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
- **Packing list menu** — no longer cut off by overflow (#557)
- **Trip date change** — preserving day content when date range changes
- **PDF export** — render restaurant, event, tour, and other reservation types
---
## Admin Panel Improvements
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
- **Naver List Import** — now always enabled, removed from addon toggles
---
## Mobile Improvements
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
- **Bottom nav dark mode** — consistent dark mode styling
- **Safe area support** — proper insets for iOS PWA
---
## Test Coverage
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
- **Journey** — 89.5% new code coverage
- **CI** — client test job added alongside server tests with split coverage artifacts
---
## Bug Fixes
- Fixed OIDC-only mode login/logout loop (#491)
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
- Fixed booking date handling and file auth bugs
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
- Fixed streaming response end on client disconnect during asset pipe
- Fixed per-day transport positions for multi-day reservations
- Fixed stale budget category reset when category no longer exists
- Fixed trip redirect to plan tab when active tab addon is disabled
- Fixed reservation price/budget field visibility when budget addon disabled
- Fixed HEIC photo rendering on non-Safari browsers
- Fixed CSP path matching for paths ending in /
- Fixed avatar URLs in notifications, admin panel, and budget
- Fixed budget member avatars lost after updating item fields
- Fixed collab notes line break preservation (#608)
- Fixed weather archive date handling for future trips (#599)
- Fixed duplicate skeleton entries for multi-day places (#606)
- Fixed ghost Gallery entries in journal timeline and public share
- Fixed journey map OSM tile warning (#627)
- Fixed content divider placement in journal entries (#624)
- Fixed local photos wrong provider label (#625)
- Fixed Synology pagination and album scroll leak (#644)
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
- Fixed Nominatim User-Agent and error diagnostics
- Fixed map tooltips, journey creation, and contributor avatars
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
- Fixed stale accommodation_id on reservation update (#522)
- Fixed hardcoded Immich in toast — now uses provider_name
- Fixed MCP safeBroadcast recursive call bug
- Fixed Vite module preload polyfill CSP inline script violation
- Fixed PWA offline session redirect and file download auth (#505, #541)
---
## Security
- **hono** 4.12.9 to 4.12.12 — fixes directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), and IP restriction bypass (CVE-2026-39409)
- **@hono/node-server** 1.19.11 to 1.19.13 — fixes directory traversal (CVE-2026-39406)
- **nodemailer** 8.0.4 to 8.0.5 — fixes CRLF injection
- **OAuth 2.1 hardening** — token storage, PKCE enforcement, scope intersection validation
- **Google Maps regex** — replaced too-permissive regex with safer utility function
---
## Infrastructure
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
- **Helm chart** — moved to charts/trek/, published via helm-publisher action to gh-pages, appVersion used as default image tag
- **Docker** — workflow improvements, tag management cleanup
- **CI** — contributor workflow automation, npm audit removal from install steps, manual trigger for prerelease
---
## Contributors
Thanks to everyone who contributed to this release:
- @mauriceboe
- @jubnl
- @gravitysc
- @luojiyin1987
- @marco783
- @isaiastavares
- @tiquis0290
- @xenocent
- @gfrcsd
---
## Stats
| Metric | Value |
|--------|-------|
| Commits | 280+ |
| Merged PRs | 49 |
| Files changed | 500+ |
| Lines added | 108,000+ |
| Contributors | 12 |
---
## Upgrading
```bash
docker pull mauriceboe/trek:3.0.0
docker compose up -d
```
Migrations run automatically on startup. No manual steps required.
**Checklist:**
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
3. Enable the Journey addon in Settings > Addons to start using the travel journal
+2 -2
View File
@@ -4,10 +4,10 @@ Thanks for your interest in contributing! Please read these guidelines before op
## Ground Rules
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
+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"]
+103 -24
View File
@@ -16,6 +16,7 @@ structured API.
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Compound Tools](#compound-tools)
- [Prompts](#prompts)
- [Example](#example)
@@ -52,10 +53,11 @@ management required — just provide the server URL:
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
**What happens automatically:**
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
@@ -140,13 +142,17 @@ that match your granted scopes for that session.
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
| `journey:read` | View journeys | Journey |
| `journey:write` | Manage journeys | Journey |
| `journey:share` | Manage journey share links | Journey |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled.
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
---
@@ -167,7 +173,7 @@ that match your granted scopes for that session.
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
---
@@ -194,7 +200,6 @@ making changes.
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| Files | `trek://trips/{tripId}/files` | Files attached to a trip (excludes trashed files) |
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
@@ -214,6 +219,10 @@ These resources are only available when the corresponding addon is enabled by an
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
---
@@ -226,7 +235,23 @@ trip in a single call.
| Tool | Description |
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, files, and poll/message counts. Use this as your context loader. |
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
### Compound Tools
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
| Tool | Wraps | Description |
|---|---|---|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
---
### Trips
@@ -247,14 +272,18 @@ trip in a single call.
### Places
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
| Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `list_categories`| List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
| `list_categories` | List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
### Day Planning
@@ -273,24 +302,40 @@ trip in a single call.
### Accommodations
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
| Tool | Description |
|------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. |
### Transport
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
| Tool | Description |
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
| `delete_transport` | Delete a transport booking from a trip. |
### Reservations
| Tool | Description |
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
@@ -370,7 +415,14 @@ trip in a single call.
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Collab Notes
### Airports
| Tool | Description |
|-------------------|-------------------------------------------------------------------------------------------------------------------|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
### Collab Notes _(Collab addon required)_
| Tool | Description |
|----------------------|-------------------------------------------------------------------------------------------------|
@@ -392,14 +444,14 @@ trip in a single call.
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List
### Bucket List _(Atlas addon required)_
| Tool | Description |
|---------------------------|--------------------------------------------------------------------------------------------|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas
### Atlas _(Atlas addon required)_
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
@@ -444,6 +496,33 @@ trip in a single call.
| `list_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. |
### Journey _(Journey addon required)_
| Tool | Description |
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
| `update_journey` | Update a journey's title, subtitle, or status. |
| `delete_journey` | Delete a journey. |
| `add_journey_trip` | Link an existing trip to a journey. |
| `remove_journey_trip` | Remove a trip from a journey. |
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
| `delete_journey_entry` | Remove an entry from a journey. |
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
| `remove_journey_contributor` | Remove a contributor from a journey. |
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
| `get_journey_share_link` | Get the current public share link for a journey. |
| `create_journey_share_link` | Create or update the public share link for a journey. |
| `delete_journey_share_link` | Revoke the public share link for a journey. |
---
## Prompts
+289 -216
View File
@@ -1,121 +1,174 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
</picture>
<br />
<em>Your Trips. Your Plan.</em>
</p>
<div align="center">
<p align="center">
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture>
<p align="center">
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
<br />
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
</p>
<br />
![TREK Screenshot](docs/screenshot.png)
![TREK Screenshot 2](docs/screenshot-2.png)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br />
<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;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
</div>
---
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
</picture>
<details>
<summary>More Screenshots</summary>
<summary><b>See all features</b></summary>
| | |
|---|---|
| ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) |
| ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) |
| ![Collab](docs/screenshot-collab.png) | |
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Collab suite** — group chat, shared notes, polls, day check-ins
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Naver List Import** — one-click import from shared Naver Maps lists
- **MCP** — expose TREK to AI assistants via OAuth 2.1
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
</td>
</tr>
</table>
</details>
## Features
<br />
### Trip Planning
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
- **Route Optimization** — Auto-optimize place order and export to Google Maps
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
- **Map Category Filter** — Filter places by category and see only matching pins on the map
### Travel Management
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
### Mobile & PWA
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
### Collaboration
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
### Addons (modular, admin-toggleable)
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### AI / MCP Integration
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
### Customization & Admin
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
## Tech Stack
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
- **Frontend**: React 18 + Vite + Tailwind CSS
- **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`)
- **State**: Zustand
- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react
## Helm (Kubernetes)
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](charts/README.md) for configuration options.
## Quick Start
## Get started in 30 seconds
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
@@ -123,19 +176,40 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
The app runs on port `3000`. The first user to register becomes the admin.
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
### Install as App (PWA)
<div align="center">
TREK works as a Progressive Web App — no App Store needed:
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
1. Open your TREK instance in the browser (HTTPS required)
2. **iOS**: Share button → "Add to Home Screen"
3. **Android**: Menu → "Install app" or "Add to Home Screen"
4. TREK launches fullscreen with its own icon, just like a native app
</div>
<br />
## Tech stack
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_18-61DAFB?style=flat-square&logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Tailwind](https://img.shields.io/badge/Tailwind-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)
![Leaflet](https://img.shields.io/badge/Leaflet-199900?style=flat-square&logo=leaflet&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white)
</div>
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br />
<h2 id="docker-compose-production">Docker Compose (production)</h2>
<details>
<summary>Docker Compose (recommended for production)</summary>
<summary>Full compose example with secure defaults</summary>
```yaml
services:
@@ -158,30 +232,19 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-info}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- APP_URL=${APP_URL:-} # required for OIDC + email links
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
# - TRUST_PROXY=1
# - OIDC_ISSUER=https://auth.example.com
# - OIDC_CLIENT_ID=trek
# - OIDC_CLIENT_SECRET=supersecret
# - OIDC_DISPLAY_NAME=SSO
# - OIDC_ADMIN_CLAIM=groups
# - OIDC_ADMIN_VALUE=app-trek-admins
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -194,29 +257,49 @@ services:
start_period: 15s
```
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
Then:
```bash
docker compose up -d
```
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
### Updating
<br />
**Docker Compose** (recommended):
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```bash
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
TREK works as a Progressive Web App — no App Store needed.
1. Open TREK in the browser (HTTPS required)
2. **iOS**: Share ▸ *Add to Home Screen*
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
TREK then launches fullscreen with its own icon, just like a native app.
<br />
## Updating
**Docker Compose:**
```bash
docker compose pull && docker compose up -d
```
**Docker Run** — use the same volume paths from your original `docker run` command:
**Docker run**reuse the original volume paths:
```bash
docker pull mauriceboe/trek
@@ -224,27 +307,23 @@ docker rm -f trek
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
### Rotating the Encryption Key
<h3>Rotating the Encryption Key</h3>
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
```bash
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
```
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
<h2 id="reverse-proxy">Reverse Proxy</h2>
### Reverse Proxy (recommended)
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
<details>
<summary>Nginx</summary>
@@ -260,8 +339,20 @@ server {
listen 443 ssl http2;
server_name trek.yourdomain.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://localhost:3000;
@@ -269,21 +360,7 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
# uploads directory and may exceed that — raise this value if restores fail.
client_max_body_size 500m;
}
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
@@ -293,17 +370,24 @@ server {
<details>
<summary>Caddy</summary>
Caddy handles WebSocket upgrades automatically:
```
```caddy
trek.yourdomain.com {
reverse_proxy localhost:3000
}
```
Caddy handles TLS and WebSockets automatically.
</details>
## Environment Variables
<br />
## Environment variables
<details>
<summary><b>Full reference</b></summary>
<br />
| Variable | Description | Default |
|----------|-------------|---------|
@@ -313,58 +397,47 @@ trek.yourdomain.com {
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
| **Initial Setup** | | |
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
| **Initial setup** | | |
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
## Optional API Keys
</details>
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
### Google Maps (Place Search & Photos)
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project and enable the **Places API (New)**
3. Create an API key under Credentials
4. In TREK: Admin Panel → Settings → Google Maps
## Building from Source
```bash
git clone https://github.com/mauriceboe/TREK.git
cd TREK
docker build -t trek .
```
<br />
## Data & Backups
- **Database**: SQLite, stored in `./data/travel.db`
- **Uploads**: Stored in `./uploads/`
- **Logs**: `./data/logs/trek.log` (auto-rotated)
- **Backups**: Create and restore via Admin Panel
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
- **Database** SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Logs** `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
<br />
## License
[AGPL-3.0](LICENSE)
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
+1 -1
View File
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue
2. Email: **mauriceboe@icloud.com**
2. Email: **report@liketrek.com**
3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
+121
View File
@@ -0,0 +1,121 @@
# Trademark Policy
## Introduction
This is the TREK project's policy for the use of our trademarks. While TREK is
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
license does not include a license to use our trademarks.
This policy describes how you may use our trademarks. Our goal is to strike a
balance between: 1) our need to ensure that our trademarks remain reliable
indicators of the software we release; and 2) our community members' desire to
be full participants in the TREK project.
## Our trademarks
This policy covers the name "TREK" as well as any associated logos, trade dress,
goodwill, or designs (our "Marks").
## In general
Whenever you use our Marks, you must always do so in a way that does not mislead
anyone about exactly who is the source of the software. For example, you cannot
say you are distributing TREK when you're distributing a modified version of it,
because people would think they would be getting the same software that they
can get directly from us when they aren't. You also cannot use our Marks on
your website in a way that suggests that your website is an official TREK
website or that we endorse your website. But, if true, you can say you like
TREK, that you participate in the TREK community, that you are providing an
unmodified version of TREK, or that you wrote a guide describing how to use
TREK.
This fundamental requirement, that it is always clear to people what they are
getting and from whom, is reflected throughout this policy. It should also
serve as your guide if you are not sure about how you are using the Marks.
In addition:
* You may not use or register, in whole or in part, the Marks as part of your
own trademark, service mark, domain name, company name, trade name, product
name or service name.
* Trademark law does not allow your use of names or trademarks that are too
similar to ours. You therefore may not use an obvious variation of any of our
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
abbreviation for a similar or compatible product or service.
* You agree that you will not acquire any rights in the Marks and that any
goodwill generated by your use of the Marks and participation in our
community inures solely to our benefit.
## Distribution of unmodified source code or unmodified executable code we have compiled
When you redistribute an unmodified copy of TREK, you are not changing the
quality or nature of it. Therefore, you may retain the Marks we have placed on
the software to identify your redistribution. This kind of use only applies if
you are redistributing an official TREK distribution that has not been changed
in any way.
## Distribution of executable code that you have compiled, or modified code
You may use the word mark "TREK", but not any TREK logos, to truthfully
describe the origin of the software that you are providing, that is, that the
code you are distributing is a modification of TREK. You may say, for example,
that "this software is derived from the source code for TREK."
Of course, you can place your own trademarks or logos on versions of the
software to which you have made substantive modifications, because by modifying
the software, you have become the origin of that exact version. In that case,
you should not use our Marks.
However, you may use our Marks for the distribution of code (source or
executable) on the condition that any executable is built from an official TREK
source code release and that any modifications are limited to switching on or
off features already included in the software, translations into other
languages, and incorporating minor bug-fix patches. Use of our Marks on any
further modification is not permitted.
## Mobile wrappers, hosted instances, and forks
The following clarifications apply specifically to common ways TREK is
redistributed:
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
as "a TREK instance" or "running TREK." You may not name the service itself
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
Official," etc.).
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
TREK.** You may describe your app as "a mobile client for TREK" or "for use
with TREK." You may not publish it on app stores under the name "TREK" or a
confusingly similar name, and you may not use the TREK logo as the app icon
unless your wrapper distributes only an unmodified, official TREK instance
and you have obtained permission.
* **Forks of the TREK source code.** Forks that diverge from upstream must use
a different name. You may state that your fork is "based on TREK" or "a fork
of TREK," but the project name itself must be your own.
## Statements about your software's relation to TREK
You may use the word mark, but not TREK logos, to truthfully describe the
relationship between your software and ours. The word mark "TREK" should be
used after a verb or preposition that describes the relationship between your
software and ours. So you may say, for example, "Bob's app for TREK" but may
not say "Bob's TREK app." Some other examples that may work for you are:
* [Your software] uses TREK
* [Your software] is powered by TREK
* [Your software] runs on TREK
* [Your software] for use with TREK
* [Your software] for TREK
## Questions and permission requests
If you are not sure whether your intended use of the Marks is permitted under
this policy, or if you would like to request explicit permission for a use that
is not covered, please open an issue on the TREK GitHub repository or contact
the maintainers directly.
---
These guidelines are based on the
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
under a
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 2.9.13
version: 3.0.22
description: Minimal Helm chart for TREK app
appVersion: "2.9.13"
appVersion: "3.0.22"
+3
View File
@@ -22,6 +22,9 @@ data:
{{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }}
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
+2
View File
@@ -30,6 +30,8 @@ env:
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# HSTS_INCLUDE_SUBDOMAINS: "false"
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1"
+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"
]
}
+39
View File
@@ -0,0 +1,39 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import gitignore from 'eslint-config-flat-gitignore'
export default defineConfig([
gitignore({ strict: false }),
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
// Route files always export both `Route` (non-component) and the page component — expected pattern.
{
files: ['src/routes/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
// shadcn UI primitives export variant helpers alongside components — generated files, don't modify.
// ThemeProvider exports both the provider component and the useTheme hook — standard pattern.
{
files: ['src/components/ui/**/*.{ts,tsx}', 'src/components/theme/ThemeProvider.tsx'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
])
+21 -4
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.9.13",
"name": "@trek/client",
"version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
@@ -12,14 +12,20 @@
"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 .",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@trek/shared": "*",
"@react-pdf/renderer": "^4.3.2",
"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",
@@ -29,9 +35,11 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -54,6 +62,15 @@
"typescript": "^6.0.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"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",
"typescript-eslint": "^8.58.2"
}
}
+161 -182
View File
@@ -1,31 +1,30 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser, buildSettings } from '../tests/helpers/factories'
import App from './App'
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSettings, buildUser } from '../tests/helpers/factories';
import { server } from '../tests/helpers/msw/server';
import { resetAllStores } from '../tests/helpers/store';
import App from './App';
import { useAuthStore } from './store/authStore';
import { useSettingsStore } from './store/settingsStore';
// ── Mock page components ───────────────────────────────────────────────────────
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }))
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }))
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }))
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }))
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }))
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }))
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }))
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }))
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }));
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }));
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }));
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }));
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }));
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }));
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }));
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }));
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }));
// Prevent WebSocket side effects from the notification listener
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}))
}));
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -34,7 +33,7 @@ function renderApp(initialPath = '/') {
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
)
);
}
/**
@@ -49,64 +48,64 @@ function seedAuth(overrides: Record<string, unknown> = {}) {
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
...overrides,
})
});
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
document.documentElement.classList.remove('dark')
})
resetAllStores();
vi.clearAllMocks();
document.documentElement.classList.remove('dark');
});
// ── RootRedirect ───────────────────────────────────────────────────────────────
describe('RootRedirect', () => {
it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
seedAuth({ isAuthenticated: false });
renderApp('/');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
seedAuth({ isAuthenticated: true, user: buildUser() });
renderApp('/');
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
});
it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => {
seedAuth({ isLoading: true, isAuthenticated: false })
renderApp('/')
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
})
seedAuth({ isLoading: true, isAuthenticated: false });
renderApp('/');
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
expect(screen.queryByText('Login')).not.toBeInTheDocument();
});
});
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
describe('ProtectedRoute — unauthenticated', () => {
it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
seedAuth({ isAuthenticated: false });
renderApp('/dashboard');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/trips/42')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
})
seedAuth({ isAuthenticated: false });
renderApp('/trips/42');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
});
// ── ProtectedRoute — loading ───────────────────────────────────────────────────
describe('ProtectedRoute — loading state', () => {
it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => {
seedAuth({ isLoading: true, isAuthenticated: false })
renderApp('/dashboard')
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
})
})
seedAuth({ isLoading: true, isAuthenticated: false });
renderApp('/dashboard');
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
});
});
// ── ProtectedRoute — MFA enforcement ──────────────────────────────────────────
@@ -116,32 +115,32 @@ describe('ProtectedRoute — MFA enforcement', () => {
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
})
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
})
});
renderApp('/dashboard');
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument());
});
it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
})
renderApp('/settings')
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
});
renderApp('/settings');
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument());
expect(screen.queryByText('Login')).not.toBeInTheDocument();
});
it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: true }),
})
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
})
});
renderApp('/dashboard');
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
});
});
// ── ProtectedRoute — admin role ────────────────────────────────────────────────
@@ -150,173 +149,153 @@ describe('ProtectedRoute — admin role check', () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'user' }),
})
renderApp('/admin')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
})
});
renderApp('/admin');
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument());
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
it('FE-COMP-APP-011: /admin is accessible for admin user', async () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'admin' }),
})
renderApp('/admin')
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
})
})
});
renderApp('/admin');
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument());
});
});
// ── Public routes ──────────────────────────────────────────────────────────────
describe('Public routes', () => {
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/login')
expect(screen.getByText('Login')).toBeInTheDocument()
})
seedAuth({ isAuthenticated: false });
renderApp('/login');
expect(screen.getByText('Login')).toBeInTheDocument();
});
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/shared/sometoken')
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
})
seedAuth({ isAuthenticated: false });
renderApp('/shared/sometoken');
expect(screen.getByText('SharedTrip')).toBeInTheDocument();
});
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/does-not-exist')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
})
seedAuth({ isAuthenticated: false });
renderApp('/does-not-exist');
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
});
});
// ── App — on-mount effects ─────────────────────────────────────────────────────
describe('App — on-mount effects', () => {
it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined)
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
renderApp('/dashboard')
expect(loadUser).toHaveBeenCalled()
})
const loadUser = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser });
renderApp('/dashboard');
expect(loadUser).toHaveBeenCalled();
});
it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined)
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
renderApp('/shared/token123')
expect(loadUser).not.toHaveBeenCalled()
})
const loadUser = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser });
renderApp('/shared/token123');
expect(loadUser).not.toHaveBeenCalled();
});
it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => {
let configCalled = false
let configCalled = false;
server.use(
http.get('/api/auth/app-config', () => {
configCalled = true
return HttpResponse.json({})
configCalled = true;
return HttpResponse.json({});
})
)
seedAuth()
renderApp('/')
await waitFor(() => expect(configCalled).toBe(true))
})
);
seedAuth();
renderApp('/');
await waitFor(() => expect(configCalled).toBe(true));
});
it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => {
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))
)
const setDemoMode = vi.fn()
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true })));
const setDemoMode = vi.fn();
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setDemoMode,
})
renderApp('/')
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
})
});
renderApp('/');
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true));
});
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ loadSettings })
renderApp('/dashboard')
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
})
})
const loadSettings = vi.fn().mockResolvedValue(undefined);
seedAuth({ isAuthenticated: true, user: buildUser() });
useSettingsStore.setState({ loadSettings });
renderApp('/dashboard');
await waitFor(() => expect(loadSettings).toHaveBeenCalled());
});
});
// ── Dark mode effects ──────────────────────────────────────────────────────────
describe('Dark mode effects', () => {
it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
renderApp('/dashboard')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(true)
)
})
seedAuth({ isAuthenticated: true, user: buildUser() });
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) });
renderApp('/dashboard');
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true));
});
it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => {
document.documentElement.classList.add('dark')
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) })
renderApp('/dashboard')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
document.documentElement.classList.add('dark');
seedAuth({ isAuthenticated: true, user: buildUser() });
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) });
renderApp('/dashboard');
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
});
it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => {
document.documentElement.classList.add('dark')
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) })
renderApp('/shared/tok')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
document.documentElement.classList.add('dark');
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) });
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) });
renderApp('/shared/tok');
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
});
it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => {
// matchMedia stub returns matches: false by default (from setup.ts)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) })
renderApp('/dashboard')
seedAuth({ isAuthenticated: true, user: buildUser() });
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) });
renderApp('/dashboard');
// With matches: false, dark should NOT be added
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
})
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false));
});
});
// ── Version cache-busting ──────────────────────────────────────────────────────
describe('Version cache-busting', () => {
it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => {
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() =>
expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')
)
})
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })));
seedAuth();
renderApp('/');
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'));
});
it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => {
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
localStorage.setItem('trek_app_version', '2.9.9');
const reload = vi.fn();
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, reload },
})
});
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(reload).toHaveBeenCalled())
})
})
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })));
seedAuth();
renderApp('/');
await waitFor(() => expect(reload).toHaveBeenCalled());
});
});
+180 -121
View File
@@ -1,189 +1,246 @@
import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
import JourneyPublicPage from './pages/JourneyPublicPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import { ReactNode, useEffect } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { authApi } from './api/client';
import BottomNav from './components/Layout/BottomNav';
import OfflineBanner from './components/Layout/OfflineBanner';
import { ToastContainer } from './components/shared/Toast';
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js';
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts';
import { TranslationProvider, useTranslation } from './i18n';
import AdminPage from './pages/AdminPage';
import AtlasPage from './pages/AtlasPage';
import DashboardPage from './pages/DashboardPage';
import FilesPage from './pages/FilesPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx';
import JourneyDetailPage from './pages/JourneyDetailPage';
import JourneyPage from './pages/JourneyPage';
import JourneyPublicPage from './pages/JourneyPublicPage';
import LoginPage from './pages/LoginPage';
import OAuthAuthorizePage from './pages/OAuthAuthorizePage';
import ResetPasswordPage from './pages/ResetPasswordPage';
import SettingsPage from './pages/SettingsPage';
import SharedTripPage from './pages/SharedTripPage';
import TripPlannerPage from './pages/TripPlannerPage';
import VacayPage from './pages/VacayPage';
import { useAddonStore } from './store/addonStore';
import { useAuthStore } from './store/authStore';
import { PermissionLevel, usePermissionsStore } from './store/permissionsStore';
import { useSettingsStore } from './store/settingsStore';
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers';
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js';
interface ProtectedRouteProps {
children: ReactNode
adminRequired?: boolean
children: ReactNode;
adminRequired?: boolean;
addonId?: string;
}
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
const { t } = useTranslation()
const location = useLocation()
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const appRequireMfa = useAuthStore((s) => s.appRequireMfa);
const addonStore = useAddonStore();
const { t } = useTranslation();
const location = useLocation();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
<p className="text-sm text-slate-500">{t('common.loading')}</p>
</div>
</div>
)
);
}
if (!isAuthenticated) {
const redirectParam = encodeURIComponent(location.pathname + location.search)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash);
return <Navigate to={`/login?redirect=${redirectParam}`} replace />;
}
if (
appRequireMfa &&
user &&
!user.mfa_enabled &&
location.pathname !== '/settings'
) {
return <Navigate to="/settings?mfa=required" replace />
if (appRequireMfa && user && !user.mfa_enabled && location.pathname !== '/settings') {
return <Navigate to="/settings?mfa=required" replace />;
}
if (adminRequired && user && user.role !== 'admin') {
return <Navigate to="/dashboard" replace />
return <Navigate to="/dashboard" replace />;
}
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
return <Navigate to="/dashboard" replace />;
}
return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex h-screen flex-col md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
);
}
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore()
const { isAuthenticated, isLoading } = useAuthStore();
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-900 rounded-full animate-spin"></div>
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
</div>
)
);
}
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />;
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const {
loadUser,
isAuthenticated,
demoMode,
setDemoMode,
setDevMode,
setIsPrerelease,
setAppVersion,
setHasMapsKey,
setServerTimezone,
setAppRequireMfa,
setTripRemindersEnabled,
setPlacesPhotosEnabled,
setPlacesAutocompleteEnabled,
setPlacesDetailsEnabled,
} = useAuthStore();
const { loadSettings } = useSettingsStore();
const { loadAddons } = useAddonStore();
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
if (
!location.pathname.startsWith('/shared/') &&
!location.pathname.startsWith('/public/') &&
!location.pathname.startsWith('/login')
) {
// If the persist snapshot already has an authenticated user, validate
// silently so the PWA shell renders immediately without a spinner.
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated;
if (alreadyAuthenticated) {
useAuthStore.setState({ isLoading: false })
loadUser({ silent: true })
useAuthStore.setState({ isLoading: false });
loadUser({ silent: true });
} else {
loadUser()
loadUser();
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
authApi
.getAppConfig()
.then(
async (config: {
demo_mode?: boolean;
dev_mode?: boolean;
is_prerelease?: boolean;
has_maps_key?: boolean;
version?: string;
timezone?: string;
require_mfa?: boolean;
trip_reminders_enabled?: boolean;
places_photos_enabled?: boolean;
places_autocomplete_enabled?: boolean;
places_details_enabled?: boolean;
permissions?: Record<string, PermissionLevel>;
}) => {
if (config?.demo_mode) setDemoMode(true);
if (config?.dev_mode) setDevMode(true);
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease);
if (config?.version) setAppVersion(config.version);
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key);
if (config?.timezone) setServerTimezone(config.timezone);
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa);
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled);
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled);
if (config?.places_autocomplete_enabled !== undefined)
setPlacesAutocompleteEnabled(config.places_autocomplete_enabled);
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled);
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions);
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')
if (storedVersion && storedVersion !== config.version) {
try {
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version');
if (storedVersion && storedVersion !== config.version) {
try {
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(names.map((n) => caches.delete(n)));
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
} catch {}
localStorage.setItem('trek_app_version', config.version);
window.location.reload();
return;
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))
}
} catch {}
localStorage.setItem('trek_app_version', config.version)
window.location.reload()
return
localStorage.setItem('trek_app_version', config.version);
}
}
localStorage.setItem('trek_app_version', config.version)
}
}).catch(() => {})
}, [])
)
.catch(() => {});
}, []);
const { settings } = useSettingsStore()
const { settings } = useSettingsStore();
useInAppNotificationListener()
useInAppNotificationListener();
useEffect(() => {
if (isAuthenticated) {
loadSettings()
loadSettings();
loadAddons();
}
}, [isAuthenticated])
}, [isAuthenticated]);
useEffect(() => {
registerSyncTriggers()
return () => unregisterSyncTriggers()
}, [])
registerSyncTriggers();
return () => unregisterSyncTriggers();
}, []);
const location = useLocation()
const isSharedPage = location.pathname.startsWith('/shared/')
const location = useLocation();
const isSharedPage = location.pathname.startsWith('/shared/');
useEffect(() => {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark')
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', '#ffffff')
return
document.documentElement.classList.remove('dark');
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', '#ffffff');
return;
}
const mode = settings.dark_mode
const mode = settings.dark_mode;
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
document.documentElement.classList.toggle('dark', isDark);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff');
};
if (mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
const mq = window.matchMedia('(prefers-color-scheme: dark)');
applyDark(mq.matches);
const handler = (e: MediaQueryListEvent) => applyDark(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
applyDark(mode === true || mode === 'dark');
}, [settings.dark_mode, isSharedPage]);
const isAuthPage =
location.pathname.startsWith('/login') ||
location.pathname.startsWith('/register') ||
location.pathname.startsWith('/forgot-password') ||
location.pathname.startsWith('/reset-password');
return (
<TranslationProvider>
{!isAuthPage && <SystemNoticeHost />}
<ToastContainer />
<OfflineBanner />
<Routes>
@@ -192,8 +249,10 @@ export default function App() {
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
@@ -253,7 +312,7 @@ export default function App() {
<Route
path="/journey"
element={
<ProtectedRoute>
<ProtectedRoute addonId="journey">
<JourneyPage />
</ProtectedRoute>
}
@@ -261,7 +320,7 @@ export default function App() {
<Route
path="/journey/:id"
element={
<ProtectedRoute>
<ProtectedRoute addonId="journey">
<JourneyDetailPage />
</ProtectedRoute>
}
@@ -277,5 +336,5 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
)
);
}
+174 -70
View File
@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from 'axios'
import type { WeatherResult } 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'
@@ -33,6 +35,7 @@ function translateRateLimit(): string {
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
@@ -42,55 +45,110 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
// Request interceptor - add socket ID + idempotency key for mutating requests
apiClient.interceptors.request.use(
(config) => {
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
// Attach a per-request idempotency key to all write operations so the
// server can deduplicate retried requests (e.g. network blips).
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
}
return config
},
(error) => Promise.reject(error)
(config) => {
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
// Attach a per-request idempotency key to all write operations so the
// server can deduplicate retried requests (e.g. network blips).
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle 401, 403 MFA, 429 rate limit
export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Unregisters the SW before reloading so the navigation reaches the network.
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
async function unregisterSWAndReload(): Promise<void> {
try {
const reg = await navigator.serviceWorker?.getRegistration()
if (reg) await reg.unregister()
} catch { /* ignore */ }
window.location.reload()
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
const currentPath = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
(response) => {
sessionStorage.removeItem('proxy_reauth_attempted')
return response
},
async (error) => {
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
// as a CORS error with no response object. Probe the health endpoint to
// distinguish a proxy auth challenge from a genuine outage. If the server
// is reachable, a top-level reload lets the edge proxy run its auth flow.
if (!error.response && navigator.onLine) {
await probeNow()
// Both the original request and the health probe failed while the device
// has a network interface. This matches the proxy-auth-challenge pattern
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
// Guard with sessionStorage to prevent reload loops (server genuinely
// down would also land here, but only reloads once).
if (!isReachable()) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
}
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
// Pangolin header-auth extended compatibility mode: returns 401 with an
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
// always application/json, so checking for text/html is unambiguous.
if (error.response?.status === 401) {
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
if (ct.includes('text/html')) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
error.message = translated
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
}
error.message = translated
}
return Promise.reject(error)
}
return Promise.reject(error)
}
)
export const authApi = {
@@ -114,6 +172,8 @@ export const authApi = {
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 }),
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
mcpTokens: {
@@ -133,6 +193,7 @@ export const oauthApi = {
state?: string
code_challenge: string
code_challenge_method: string
resource?: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
@@ -144,12 +205,13 @@ export const oauthApi = {
code_challenge: string
code_challenge_method: string
approved: boolean
resource?: string
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
},
@@ -190,18 +252,27 @@ export const placesApi = {
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, file: File) => {
const fd = new FormData(); fd.append('file', file)
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importMapFile: (tripId: number | string, file: File) => {
const fd = new FormData(); fd.append('file', file)
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
const fd = new FormData()
fd.append('file', file)
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
}
export const assignmentsApi = {
@@ -272,6 +343,14 @@ export const adminApi = {
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
@@ -287,7 +366,7 @@ export const adminApi = {
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
auditLog: (params?: { limit?: number; offset?: number }) =>
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
@@ -296,9 +375,11 @@ export const adminApi = {
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
sendTestNotification: (data: Record<string, unknown>) =>
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
}
export const addonsApi = {
@@ -324,12 +405,29 @@ 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),
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).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 } : {}) }).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),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -354,13 +452,18 @@ export const journeyApi = {
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
}
export const airportsApi = {
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
@@ -399,13 +502,13 @@ export const reservationsApi = {
}
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 => r.data),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
}
export const configApi = {
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
apiClient.get('/config').then(r => r.data),
apiClient.get('/config').then(r => r.data),
}
export const settingsApi = {
@@ -486,25 +589,26 @@ export const notificationsApi = {
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
}
export const inAppNotificationsApi = {
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
unreadCount: () =>
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
markRead: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
markUnread: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
markAllRead: () =>
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
delete: (id: number) =>
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
deleteAll: () =>
apiClient.delete('/notifications/in-app/all').then(r => r.data),
apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: 'positive' | 'negative') =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
}
export default apiClient
export default apiClient
+2 -2
View File
@@ -32,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
expect(ALL_SCOPES).toHaveLength(24)
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
expect(ALL_SCOPES).toHaveLength(27)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
+3
View File
@@ -38,6 +38,9 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
@@ -1,11 +1,11 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { ToastContainer } from '../shared/Toast';
import AddonManager from './AddonManager';
@@ -36,9 +36,7 @@ beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
);
server.use(http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })));
});
afterEach(() => {
@@ -49,7 +47,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
server.use(
http.get('/api/admin/addons', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
})
);
@@ -95,19 +93,20 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
),
http.put('/api/admin/addons/todo', () =>
HttpResponse.json({ success: true })
)
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AddonManager />
</>
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
// Get toggle button - use getAllByRole since there might be multiple buttons
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full'));
expect(toggleBtn).toBeInTheDocument();
// Before click - disabled state (border-primary bg)
@@ -120,18 +119,19 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
),
http.put('/api/admin/addons/todo', () =>
HttpResponse.error()
)
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
http.put('/api/admin/addons/todo', () => HttpResponse.error())
);
render(
<>
<ToastContainer />
<AddonManager />
</>
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full'));
await user.click(toggleBtn!);
// Error toast appears
@@ -148,19 +148,18 @@ describe('AddonManager', () => {
const user = userEvent.setup();
const mockToggle = vi.fn();
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
)
);
render(
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
);
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />);
await screen.findByText('Bag Tracking');
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
);
const bagTrackingToggle = screen
.getAllByRole('button')
.find(
(b) =>
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
);
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
const allBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full'));
// There should be two toggle buttons: one for the addon, one for bag tracking
await user.click(allBtns[allBtns.length - 1]);
expect(mockToggle).toHaveBeenCalled();
@@ -172,18 +171,14 @@ describe('AddonManager', () => {
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
)
);
render(
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
);
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
)
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
);
render(<AddonManager bagTrackingEnabled={false} />);
await screen.findByText('Lists');
@@ -213,7 +208,7 @@ describe('AddonManager', () => {
expect(screen.getByText('Journey')).toBeInTheDocument();
// Toggle buttons: journey toggle + 2 provider toggles
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
const toggleBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full'));
expect(toggleBtns.length).toBe(3);
});
+397 -148
View File
@@ -1,138 +1,243 @@
import { useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
import {
BarChart3,
BookOpen,
Briefcase,
CalendarDays,
Compass,
FileText,
Globe,
Image,
Link2,
ListChecks,
Luggage,
MessageCircle,
Puzzle,
Sparkles,
StickyNote,
Terminal,
Wallet,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useToast } from '../shared/Toast';
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
ListChecks,
Wallet,
FileText,
CalendarDays,
Puzzle,
Globe,
Briefcase,
Image,
Terminal,
Link2,
Compass,
BookOpen,
};
function ImmichIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path
d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321"
fill="currentColor"
/>
</svg>
);
}
function SynologyIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path
d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z"
fill="currentColor"
/>
</svg>
);
}
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
};
interface Addon {
id: string
name: string
description: string
icon: string
type: string
enabled: boolean
config?: Record<string, unknown>
id: string;
name: string;
description: string;
icon: string;
type: string;
enabled: boolean;
config?: Record<string, unknown>;
}
interface ProviderOption {
key: string
label: string
description: string
enabled: boolean
toggle: () => Promise<void>
key: string;
label: string;
description: string;
enabled: boolean;
toggle: () => Promise<void>;
}
interface AddonIconProps {
name: string
size?: number
name: string;
size?: number;
}
function AddonIcon({ name, size = 20 }: AddonIconProps) {
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
const Icon = ICON_MAP[name] || Puzzle;
return <Icon size={size} />;
}
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
const { t } = useTranslation()
const dm = useSettingsStore(s => s.settings.dark_mode)
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const toast = useToast()
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
const [addons, setAddons] = useState<Addon[]>([])
const [loading, setLoading] = useState(true)
interface CollabFeatures {
chat: boolean;
notes: boolean;
polls: boolean;
whatsnext: boolean;
}
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{
key: 'whatsnext',
icon: Sparkles,
titleKey: 'admin.collab.whatsnext.title',
subtitleKey: 'admin.collab.whatsnext.subtitle',
},
] as const;
export default function AddonManager({
bagTrackingEnabled,
onToggleBagTracking,
collabFeatures,
onToggleCollabFeature,
}: {
bagTrackingEnabled?: boolean;
onToggleBagTracking?: () => void;
collabFeatures?: CollabFeatures;
onToggleCollabFeature?: (key: string) => void;
}) {
const { t } = useTranslation();
const dm = useSettingsStore((s) => s.settings.dark_mode);
const dark =
dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const toast = useToast();
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadAddons()
}, [])
loadAddons();
}, []);
const loadAddons = async () => {
setLoading(true)
setLoading(true);
try {
const data = await adminApi.addons()
setAddons(data.addons)
const data = await adminApi.addons();
setAddons(data.addons);
} catch (err: unknown) {
toast.error(t('admin.addons.toast.error'))
toast.error(t('admin.addons.toast.error'));
} finally {
setLoading(false)
setLoading(false);
}
}
};
const handleToggle = async (addon: Addon) => {
const newEnabled = !addon.enabled
const newEnabled = !addon.enabled;
// Optimistic update
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: newEnabled } : a)));
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
refreshGlobalAddons()
toast.success(t('admin.addons.toast.updated'))
await adminApi.updateAddon(addon.id, { enabled: newEnabled });
refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
} catch (err: unknown) {
// Rollback
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
toast.error(t('admin.addons.toast.error'))
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: !newEnabled } : a)));
toast.error(t('admin.addons.toast.error'));
}
}
};
const isPhotoProviderAddon = (addon: Addon) => {
return addon.type === 'photo_provider'
}
return addon.type === 'photo_provider';
};
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase();
return (
addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
);
};
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
const enableProvider = !providerAddon.enabled
const prev = addons
const enableProvider = !providerAddon.enabled;
const prev = addons;
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
setAddons((current) => current.map((a) => (a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)));
try {
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
refreshGlobalAddons()
toast.success(t('admin.addons.toast.updated'))
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider });
refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
} catch {
setAddons(prev)
toast.error(t('admin.addons.toast.error'))
setAddons(prev);
toast.error(t('admin.addons.toast.error'));
}
}
};
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
const photoProviderAddons = addons.filter(isPhotoProviderAddon);
const photosAddon = addons.filter((a) => a.type === 'trip').find(isPhotosAddon);
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
const globalAddons = addons.filter((a) => a.type === 'global');
const integrationAddons = addons.filter((a) => a.type === 'integration');
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleTogglePhotoProvider(provider),
}))
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleTogglePhotoProvider(provider),
}));
const photosDerivedEnabled = providerOptions.some((p) => p.enabled);
if (loading) {
return (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
<div
className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900"
style={{ borderTopColor: 'var(--text-primary)' }}
></div>
</div>
)
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="border-b px-6 py-4" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.addons.title')}
</h2>
<p
className="mt-1 text-xs"
style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}
>
{t('admin.addons.subtitleBefore')}
<img
src={dark ? '/text-light.svg' : '/text-dark.svg'}
alt="TREK"
style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }}
/>
{t('admin.addons.subtitleAfter')}
</p>
</div>
@@ -145,34 +250,104 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<div
className="flex items-center gap-2 border-b px-6 py-2.5"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
>
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.trip')} {t('admin.addons.tripHint')}
</span>
</div>
{tripAddons.map(addon => (
{tripAddons.map((addon) => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div
className="flex items-center gap-4 border-b px-6 py-3"
style={{
borderColor: 'var(--border-secondary)',
background: 'var(--bg-secondary)',
paddingLeft: 70,
}}
>
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('admin.bagTracking.title')}
</div>
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.bagTracking.subtitle')}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={onToggleBagTracking}
<button
onClick={onToggleBagTracking}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
</div>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div
className="border-b px-6 py-3"
style={{
borderColor: 'var(--border-secondary)',
background: 'var(--bg-secondary)',
paddingLeft: 70,
}}
>
<div className="space-y-2">
{COLLAB_SUB_FEATURES.map((feat) => {
const enabled = collabFeatures[feat.key];
const Icon = feat.icon;
return (
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t(feat.titleKey)}
</div>
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{t(feat.subtitleKey)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={() => onToggleCollabFeature(feat.key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
))}
</div>
@@ -181,40 +356,69 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<div
className="flex items-center gap-2 border-b border-t px-6 py-2.5"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
>
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.global')} {t('admin.addons.globalHint')}
</span>
</div>
{globalAddons.map(addon => (
{globalAddons.map((addon) => (
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div
className="border-b px-6 py-3"
style={{
borderColor: 'var(--border-secondary)',
background: 'var(--bg-secondary)',
paddingLeft: 70,
}}
>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
{providerOptions.map((provider) => {
const ProviderIcon = PROVIDER_ICONS[provider.key];
return (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && (
<span style={{ color: 'var(--text-faint)' }}>
<ProviderIcon size={14} />
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{provider.label}
</div>
<div className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{provider.description}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{
background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
);
})}
</div>
</div>
)}
@@ -226,13 +430,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{/* Integration Addons */}
{integrationAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<div
className="flex items-center gap-2 border-b border-t px-6 py-2.5"
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}
>
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.integration')} {t('admin.addons.integrationHint')}
</span>
</div>
{integrationAddons.map(addon => (
{integrationAddons.map((addon) => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
@@ -241,80 +448,122 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
)}
</div>
</div>
)
);
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
t: (key: string) => string
statusOverride?: boolean
hideToggle?: boolean
addon: Addon;
onToggle: (addon: Addon) => void;
t: (key: string) => string;
statusOverride?: boolean;
hideToggle?: boolean;
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
const nameKey = `admin.addons.catalog.${addon.id}.name`
const descKey = `admin.addons.catalog.${addon.id}.description`
const translatedName = t(nameKey)
const translatedDescription = t(descKey)
const nameKey = `admin.addons.catalog.${addon.id}.name`;
const descKey = `admin.addons.catalog.${addon.id}.description`;
const translatedName = t(nameKey);
const translatedDescription = t(descKey);
return {
name: translatedName !== nameKey ? translatedName : addon.name,
description: translatedDescription !== descKey ? translatedDescription : addon.description,
}
};
}
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
const isComingSoon = false
const label = getAddonLabel(t, addon)
const displayName = nameOverride || label.name
const displayDescription = descriptionOverride || label.description
const enabledState = statusOverride ?? addon.enabled
function AddonRow({
addon,
onToggle,
t,
nameOverride,
descriptionOverride,
statusOverride,
hideToggle,
}: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
const isComingSoon = false;
const label = getAddonLabel(t, addon);
const displayName = nameOverride || label.name;
const displayDescription = descriptionOverride || label.description;
const enabledState = statusOverride ?? addon.enabled;
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
<div
className="flex items-center gap-4 border-b px-6 py-4 transition-colors hover:opacity-95"
style={{
borderColor: 'var(--border-secondary)',
opacity: isComingSoon ? 0.5 : 1,
pointerEvents: isComingSoon ? 'none' : 'auto',
}}
>
{/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
>
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{displayName}
</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
<span
className="rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}
>
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
<span
className="rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
{addon.type === 'global'
? t('admin.addons.type.global')
: addon.type === 'integration'
? t('admin.addons.type.integration')
: t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
<p className="mt-0.5 text-xs" style={{ color: 'var(--text-muted)' }}>
{displayDescription}
</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
<div className="flex shrink-0 items-center gap-2">
<span
className="hidden text-xs font-medium sm:inline"
style={{ color: enabledState && !isComingSoon ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{isComingSoon
? t('admin.addons.disabled')
: enabledState
? t('admin.addons.enabled')
: t('admin.addons.disabled')}
</span>
{!hideToggle && (
<button
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
style={{
background: enabledState && !isComingSoon ? 'var(--text-primary)' : 'var(--border-primary)',
cursor: isComingSoon ? 'not-allowed' : 'pointer',
}}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: 'var(--bg-card)',
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
transform: enabledState && !isComingSoon ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
)}
</div>
</div>
)
);
}
@@ -1,8 +1,8 @@
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
@@ -39,7 +39,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/mcp-tokens', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ tokens: [] });
})
);
@@ -53,11 +53,7 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Ops Token')).toBeInTheDocument();
@@ -69,11 +65,7 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Never')).toBeInTheDocument();
@@ -81,11 +73,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
@@ -100,11 +88,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
@@ -121,11 +105,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
@@ -145,14 +125,15 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
),
http.delete('/api/admin/mcp-tokens/:id', () =>
HttpResponse.json({ success: true })
)
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
@@ -170,14 +151,15 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
),
http.delete('/api/admin/mcp-tokens/:id', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
@@ -189,19 +171,20 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens');
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/oauth-sessions', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ sessions: [] });
})
);
@@ -210,11 +193,7 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({ sessions: [] })
)
);
server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [] })));
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
});
@@ -244,13 +223,19 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
const user = userEvent.setup();
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
const scopes = [
'trips:read',
'trips:write',
'places:read',
'places:write',
'budget:read',
'budget:write',
'packing:read',
];
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
],
sessions: [{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }],
})
)
);
@@ -270,15 +255,24 @@ describe('AdminMcpTokensPanel', () => {
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
{
id: 5,
client_name: 'Revoke Me',
username: 'carol',
scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
),
http.delete('/api/admin/oauth-sessions/5', () =>
HttpResponse.json({ success: true })
)
http.delete('/api/admin/oauth-sessions/5', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Revoke Me');
// Click the revoke (trash) button next to the session
@@ -289,7 +283,7 @@ describe('AdminMcpTokensPanel', () => {
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
const confirmBtn = deleteBtns.find((b) => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
@@ -302,21 +296,30 @@ describe('AdminMcpTokensPanel', () => {
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
{
id: 6,
client_name: 'Error Session',
username: 'dave',
scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
),
http.delete('/api/admin/oauth-sessions/6', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
http.delete('/api/admin/oauth-sessions/6', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Error Session');
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
const confirmBtn = deleteBtns.find((b) => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
});
@@ -1,161 +1,212 @@
import { useState, useEffect } from 'react'
import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Key, Loader2, Shield, Trash2, User } from 'lucide-react';
import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useToast } from '../shared/Toast';
interface AdminOAuthSession {
id: number
client_id: string
client_name: string
user_id: number
username: string
scopes: string[]
access_token_expires_at: string
refresh_token_expires_at: string
created_at: string
id: number;
client_id: string;
client_name: string;
user_id: number;
username: string;
scopes: string[];
access_token_expires_at: string;
refresh_token_expires_at: string;
created_at: string;
}
interface AdminMcpToken {
id: number
name: string
token_prefix: string
created_at: string
last_used_at: string | null
user_id: number
username: string
id: number;
name: string;
token_prefix: string;
created_at: string;
last_used_at: string | null;
user_id: number;
username: string;
}
const SCOPES_PREVIEW = 6
const SCOPES_PREVIEW = 6;
export default function AdminMcpTokensPanel() {
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
const [tokensLoading, setTokensLoading] = useState(true)
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
const [sessions, setSessions] = useState<AdminOAuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [tokens, setTokens] = useState<AdminMcpToken[]>([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set());
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
const toggleScopes = (id: number) =>
setExpandedScopes(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const toast = useToast()
const { t, locale } = useTranslation()
setExpandedScopes((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toast = useToast();
const { t, locale } = useTranslation();
useEffect(() => {
adminApi.oauthSessions()
.then(d => setSessions(d.sessions || []))
adminApi
.oauthSessions()
.then((d) => setSessions(d.sessions || []))
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
.finally(() => setSessionsLoading(false))
.finally(() => setSessionsLoading(false));
adminApi.mcpTokens()
.then(d => setTokens(d.tokens || []))
adminApi
.mcpTokens()
.then((d) => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
.finally(() => setTokensLoading(false))
}, [])
.finally(() => setTokensLoading(false));
}, []);
const handleRevoke = async (id: number) => {
try {
await adminApi.revokeOAuthSession(id)
setSessions(prev => prev.filter(s => s.id !== id))
setRevokeConfirmId(null)
toast.success(t('admin.oauthSessions.revokeSuccess'))
await adminApi.revokeOAuthSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setRevokeConfirmId(null);
toast.success(t('admin.oauthSessions.revokeSuccess'));
} catch {
toast.error(t('admin.oauthSessions.revokeError'))
toast.error(t('admin.oauthSessions.revokeError'));
}
}
};
const handleDelete = async (id: number) => {
try {
await adminApi.deleteMcpToken(id)
setTokens(prev => prev.filter(tk => tk.id !== id))
setDeleteConfirmId(null)
toast.success(t('admin.mcpTokens.deleteSuccess'))
await adminApi.deleteMcpToken(id);
setTokens((prev) => prev.filter((tk) => tk.id !== id));
setDeleteConfirmId(null);
toast.success(t('admin.mcpTokens.deleteSuccess'));
} catch {
toast.error(t('admin.mcpTokens.deleteError'))
toast.error(t('admin.mcpTokens.deleteError'));
}
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.mcpTokens.title')}
</h2>
<p className="mt-0.5 text-sm" style={{ color: 'var(--text-tertiary)' }}>
{t('admin.mcpTokens.subtitle')}
</p>
</div>
{/* OAuth Sessions */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<h3 className="mb-2 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.oauthSessions.sectionTitle')}
</h3>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{sessionsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
<div className="flex flex-col items-center justify-center gap-2 py-12">
<Shield className="h-8 w-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
{t('admin.oauthSessions.empty')}
</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<div
className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 border-b px-4 py-2.5 text-xs font-medium"
style={{
color: 'var(--text-tertiary)',
borderColor: 'var(--border-primary)',
background: 'var(--bg-secondary)',
}}
>
<span>{t('admin.oauthSessions.clientName')}</span>
<span>{t('admin.oauthSessions.owner')}</span>
<span className="text-right">{t('admin.oauthSessions.created')}</span>
<span></span>
</div>
{sessions.map((session, i) => {
const expanded = expandedScopes.has(session.id)
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
const hidden = session.scopes.length - SCOPES_PREVIEW
const expanded = expandedScopes.has(session.id);
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW);
const hidden = session.scopes.length - SCOPES_PREVIEW;
return (
<div key={session.id}
<div
key={session.id}
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}
>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{visible.map(scope => (
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
<p className="truncate text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{session.client_name}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{visible.map((scope) => (
<span
key={scope}
className="inline-flex items-center rounded px-1.5 py-0.5 font-mono text-xs"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-tertiary)',
border: '1px solid var(--border-primary)',
}}
>
{scope}
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
<button
onClick={() => toggleScopes(session.id)}
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium transition-colors hover:opacity-80"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border-primary)',
}}
>
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
<button
onClick={() => toggleScopes(session.id)}
className="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium transition-colors hover:opacity-80"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border-primary)',
}}
>
show less
</button>
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<div
className="flex items-center gap-1.5 pt-0.5 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
<User className="h-3.5 w-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{session.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
<span
className="whitespace-nowrap pt-0.5 text-right text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button onClick={() => setRevokeConfirmId(session.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
<button
onClick={() => setRevokeConfirmId(session.id)}
className="rounded-lg p-1.5 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
);
})}
</>
)}
@@ -164,21 +215,34 @@ export default function AdminMcpTokensPanel() {
{/* MCP Tokens */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<h3 className="mb-2 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.mcpTokens.sectionTitle')}
</h3>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{tokensLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
<Loader2 className="h-5 w-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
<div className="flex flex-col items-center justify-center gap-2 py-12">
<Key className="h-8 w-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>
{t('admin.mcpTokens.empty')}
</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<div
className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 border-b px-4 py-2.5 text-xs font-medium"
style={{
color: 'var(--text-tertiary)',
borderColor: 'var(--border-primary)',
background: 'var(--bg-secondary)',
}}
>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
@@ -186,27 +250,38 @@ export default function AdminMcpTokensPanel() {
<span></span>
</div>
{tokens.map((token, i) => (
<div key={token.id}
<div
key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}
>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
<p className="truncate text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{token.name}
</p>
<p className="mt-0.5 font-mono text-xs" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}...
</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<User className="h-3.5 w-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
<span className="whitespace-nowrap text-right text-xs" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
<span className="whitespace-nowrap text-right text-xs" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at
? new Date(token.last_used_at).toLocaleDateString(locale)
: t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
<button
onClick={() => setDeleteConfirmId(token.id)}
className="rounded-lg p-1.5 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
@@ -217,18 +292,32 @@ export default function AdminMcpTokensPanel() {
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={(e) => {
if (e.target === e.currentTarget) setRevokeConfirmId(null);
}}
>
<div className="w-full max-w-sm space-y-4 rounded-xl p-6 shadow-xl" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.oauthSessions.revokeTitle')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('admin.oauthSessions.revokeMessage')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setRevokeConfirmId(null)}
className="rounded-lg border px-4 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
<button
onClick={() => handleRevoke(revokeConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
{t('common.delete')}
</button>
</div>
@@ -238,18 +327,32 @@ export default function AdminMcpTokensPanel() {
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={(e) => {
if (e.target === e.currentTarget) setDeleteConfirmId(null);
}}
>
<div className="w-full max-w-sm space-y-4 rounded-xl p-6 shadow-xl" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.mcpTokens.deleteTitle')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('admin.mcpTokens.deleteMessage')}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteConfirmId(null)}
className="rounded-lg border px-4 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('common.cancel')}
</button>
<button onClick={() => handleDelete(deleteConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
<button
onClick={() => handleDelete(deleteConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
{t('common.delete')}
</button>
</div>
@@ -257,5 +360,5 @@ export default function AdminMcpTokensPanel() {
</div>
)}
</div>
)
);
}
@@ -1,8 +1,8 @@
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import AuditLogPanel from './AuditLogPanel';
@@ -44,7 +44,7 @@ describe('AuditLogPanel', () => {
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
}),
})
);
render(<AuditLogPanel serverTimezone="UTC" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
@@ -52,22 +52,14 @@ describe('AuditLogPanel', () => {
});
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [], total: 0 }),
),
);
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('No audit entries yet.');
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
),
);
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('Time')).toBeInTheDocument();
@@ -89,11 +81,7 @@ describe('AuditLogPanel', () => {
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
];
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries, total: 4 }),
),
);
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.username');
expect(screen.getByText('alice')).toBeInTheDocument();
@@ -121,9 +109,7 @@ describe('AuditLogPanel', () => {
details: {},
};
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
),
http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }))
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.nulls');
@@ -133,11 +119,7 @@ describe('AuditLogPanel', () => {
});
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
),
);
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 50 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
@@ -152,7 +134,7 @@ describe('AuditLogPanel', () => {
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
}
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
}),
})
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
@@ -166,11 +148,7 @@ describe('AuditLogPanel', () => {
});
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
),
);
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 })));
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
@@ -191,7 +169,7 @@ describe('AuditLogPanel', () => {
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
}
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
}),
})
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
@@ -214,7 +192,7 @@ describe('AuditLogPanel', () => {
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
}),
})
);
render(<AuditLogPanel serverTimezone="UTC" />);
const refreshBtn = screen.getByText('Refresh');
+112 -79
View File
@@ -1,72 +1,72 @@
import React, { useCallback, useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { RefreshCw, ClipboardList } from 'lucide-react'
import { ClipboardList, RefreshCw } from 'lucide-react';
import React, { useCallback, useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
interface AuditEntry {
id: number
created_at: string
user_id: number | null
username: string | null
user_email: string | null
action: string
resource: string | null
details: Record<string, unknown> | null
ip: string | null
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: Record<string, unknown> | null;
ip: string | null;
}
interface AuditLogPanelProps {
serverTimezone?: string
serverTimezone?: string;
}
export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
const { t, locale } = useTranslation()
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(true)
const limit = 100
const { t, locale } = useTranslation();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const limit = 100;
const loadFirstPage = useCallback(async () => {
setLoading(true)
setLoading(true);
try {
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
entries: AuditEntry[]
total: number
}
setEntries(data.entries || [])
setTotal(data.total ?? 0)
setOffset(0)
const data = (await adminApi.auditLog({ limit, offset: 0 })) as {
entries: AuditEntry[];
total: number;
};
setEntries(data.entries || []);
setTotal(data.total ?? 0);
setOffset(0);
} catch {
setEntries([])
setTotal(0)
setOffset(0)
setEntries([]);
setTotal(0);
setOffset(0);
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
const loadMore = useCallback(async () => {
const nextOffset = offset + limit
setLoading(true)
const nextOffset = offset + limit;
setLoading(true);
try {
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
entries: AuditEntry[]
total: number
}
setEntries((prev) => [...prev, ...(data.entries || [])])
setTotal(data.total ?? 0)
setOffset(nextOffset)
const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as {
entries: AuditEntry[];
total: number;
};
setEntries((prev) => [...prev, ...(data.entries || [])]);
setTotal(data.total ?? 0);
setOffset(nextOffset);
} catch {
/* keep existing */
} finally {
setLoading(false)
setLoading(false);
}
}, [offset])
}, [offset]);
useEffect(() => {
loadFirstPage()
}, [loadFirstPage])
loadFirstPage();
}, [loadFirstPage]);
const fmtTime = (iso: string) => {
try {
@@ -74,43 +74,45 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
dateStyle: 'short',
timeStyle: 'medium',
timeZone: serverTimezone || undefined,
})
});
} catch {
return iso
return iso;
}
}
};
const fmtDetails = (d: Record<string, unknown> | null) => {
if (!d || Object.keys(d).length === 0) return '—'
if (!d || Object.keys(d).length === 0) return '—';
try {
return JSON.stringify(d)
return JSON.stringify(d);
} catch {
return '—'
return '—';
}
}
};
const userLabel = (e: AuditEntry) => {
if (e.username) return e.username
if (e.user_email) return e.user_email
if (e.user_id != null) return `#${e.user_id}`
return '—'
}
if (e.username) return e.username;
if (e.user_email) return e.user_email;
if (e.user_id != null) return `#${e.user_id}`;
return '—';
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<h2 className="m-0 flex items-center gap-2 text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
<ClipboardList size={20} />
{t('admin.tabs.audit')}
</h2>
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
<p className="m-0 mt-1 text-sm" style={{ color: 'var(--text-muted)' }}>
{t('admin.audit.subtitle')}
</p>
</div>
<button
type="button"
disabled={loading}
onClick={() => loadFirstPage()}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
@@ -118,36 +120,67 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
</button>
</div>
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
<p className="m-0 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.audit.showing', { count: entries.length, total })}
</p>
{loading && entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>
{t('common.loading')}
</div>
) : entries.length === 0 ? (
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>
{t('admin.audit.empty')}
</div>
) : (
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
<table className="w-full text-sm border-collapse min-w-[720px]">
<div
className="overflow-x-auto rounded-xl border"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead>
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.time')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.user')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.action')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.resource')}
</th>
<th className="whitespace-nowrap p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.ip')}
</th>
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('admin.audit.col.details')}
</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
<td className="whitespace-nowrap p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>
{fmtTime(e.created_at)}
</td>
<td className="p-3" style={{ color: 'var(--text-primary)' }}>
{userLabel(e)}
</td>
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>
{e.action}
</td>
<td className="max-w-[140px] break-all p-3 font-mono text-xs" style={{ color: 'var(--text-muted)' }}>
{e.resource || '—'}
</td>
<td className="whitespace-nowrap p-3 font-mono text-xs" style={{ color: 'var(--text-muted)' }}>
{e.ip || '—'}
</td>
<td className="max-w-[280px] break-all p-3 font-mono text-xs" style={{ color: 'var(--text-faint)' }}>
{fmtDetails(e.details)}
</td>
</tr>
))}
</tbody>
@@ -167,5 +200,5 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
</button>
)}
</div>
)
);
}
+203 -191
View File
@@ -1,23 +1,23 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { server } from '../../../tests/helpers/msw/server'
import { http, HttpResponse } from 'msw'
import BackupPanel from './BackupPanel'
import { ToastContainer } from '../shared/Toast'
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { ToastContainer } from '../shared/Toast';
import BackupPanel from './BackupPanel';
const manualBackup = {
filename: 'backup-2025-01-15.zip',
created_at: '2025-01-15T10:00:00Z',
size: 2048000,
}
};
const autoBackup = {
filename: 'auto-backup-2025-02-01.zip',
created_at: '2025-02-01T02:00:00Z',
size: 1024000,
}
};
function defaultBackupHandlers() {
return [
@@ -26,288 +26,300 @@ function defaultBackupHandlers() {
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
})
),
]
];
}
function getToggleButton() {
// The enable toggle is a <button> inside a <label> that contains "Enable auto-backup"
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement
return label.querySelector('button') as HTMLElement
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement;
return label.querySelector('button') as HTMLElement;
}
describe('BackupPanel', () => {
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
vi.spyOn(window, 'confirm').mockReturnValue(true)
server.use(...defaultBackupHandlers())
})
resetAllStores();
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any);
vi.spyOn(window, 'confirm').mockReturnValue(true);
server.use(...defaultBackupHandlers());
});
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
server.resetHandlers()
})
vi.restoreAllMocks();
vi.useRealTimers();
server.resetHandlers();
});
// BKP-001: Loading state
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
server.use(
http.get('/api/backup/list', async () => {
await new Promise(resolve => setTimeout(resolve, 300))
return HttpResponse.json({ backups: [] })
}),
)
render(<BackupPanel />)
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
})
await new Promise((resolve) => setTimeout(resolve, 300));
return HttpResponse.json({ backups: [] });
})
);
render(<BackupPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
// BKP-002: Empty state
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
server.use(
http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })),
)
render(<BackupPanel />)
server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })));
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('No backups yet')).toBeInTheDocument()
})
expect(screen.getByText('Create first backup')).toBeInTheDocument()
})
expect(screen.getByText('No backups yet')).toBeInTheDocument();
});
expect(screen.getByText('Create first backup')).toBeInTheDocument();
});
// BKP-003: Backup list renders filename, size, and date
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
render(<BackupPanel />)
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
expect(screen.getByText('2.0 MB')).toBeInTheDocument()
})
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
expect(screen.getByText('2.0 MB')).toBeInTheDocument();
});
// BKP-004: Auto-backup badge shown for auto-backup filenames
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
server.use(
http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })),
)
render(<BackupPanel />)
server.use(http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })));
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument()
})
expect(screen.getByText('Auto')).toBeInTheDocument()
})
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument();
});
expect(screen.getByText('Auto')).toBeInTheDocument();
});
// BKP-005: Create backup success
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
const user = userEvent.setup()
const user = userEvent.setup();
server.use(
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
)
render(<><ToastContainer /><BackupPanel /></>)
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] }))
);
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getByTitle('Create Backup'))
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getByTitle('Create Backup'));
await waitFor(() => {
expect(screen.getByText('Backup created successfully')).toBeInTheDocument()
})
})
expect(screen.getByText('Backup created successfully')).toBeInTheDocument();
});
});
// BKP-006: Restore opens confirmation modal
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Yes, restore')).toBeInTheDocument()
expect(screen.getByText('Cancel')).toBeInTheDocument()
})
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
});
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Yes, restore')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
// BKP-007: Cancel dismisses modal without calling restore API
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
const user = userEvent.setup()
let restoreCalled = false
const user = userEvent.setup();
let restoreCalled = false;
server.use(
http.post('/api/backup/restore/:filename', () => {
restoreCalled = true
return HttpResponse.json({ success: true })
}),
)
render(<BackupPanel />)
restoreCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
await user.click(screen.getByText('Cancel'))
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
});
await user.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
})
expect(restoreCalled).toBe(false)
})
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
});
expect(restoreCalled).toBe(false);
});
// BKP-008: Backdrop click dismisses modal
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
expect(screen.getByText('Restore Backup?')).toBeInTheDocument();
});
// Click the backdrop overlay (the fixed-position div)
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
expect(backdrop).toBeTruthy()
fireEvent.click(backdrop!)
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop!);
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
})
})
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument();
});
});
// BKP-009: Successful restore calls API and reloads after 1500ms
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })),
)
render(<><ToastContainer /><BackupPanel /></>)
const user = userEvent.setup();
server.use(http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })));
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
const reloadMock = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
const reloadMock = vi.fn();
vi.stubGlobal('location', { ...window.location, reload: reloadMock });
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument())
await user.click(screen.getByText('Yes, restore'))
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument())
await user.click(screen.getAllByText('Restore')[0]);
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument());
await user.click(screen.getByText('Yes, restore'));
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument());
// Wait for the 1500ms reload timer to fire
await new Promise(resolve => setTimeout(resolve, 1600))
expect(reloadMock).toHaveBeenCalled()
vi.unstubAllGlobals()
}, 20000)
await new Promise((resolve) => setTimeout(resolve, 1600));
expect(reloadMock).toHaveBeenCalled();
vi.unstubAllGlobals();
}, 20000);
// BKP-010: Delete backup with confirm dialog
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
const user = userEvent.setup()
server.use(
http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })),
)
render(<><ToastContainer /><BackupPanel /></>)
const user = userEvent.setup();
server.use(http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })));
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
const trashBtn = Array.from(document.querySelectorAll('button')).find(
b => b.querySelector('svg.lucide-trash2'),
) as HTMLElement
expect(trashBtn).toBeTruthy()
await user.click(trashBtn!)
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument();
});
const trashBtn = Array.from(document.querySelectorAll('button')).find((b) =>
b.querySelector('svg.lucide-trash2')
) as HTMLElement;
expect(trashBtn).toBeTruthy();
await user.click(trashBtn!);
await waitFor(() => {
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
})
expect(screen.getByText('Backup deleted')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument()
})
})
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument();
});
});
// BKP-011: Auto-backup enable toggle shows interval controls
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
})
expect(screen.queryByText('Hourly')).not.toBeInTheDocument()
await user.click(getToggleButton())
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
});
expect(screen.queryByText('Hourly')).not.toBeInTheDocument();
await user.click(getToggleButton());
await waitFor(() => {
expect(screen.getByText('Hourly')).toBeInTheDocument()
expect(screen.getByText('Daily')).toBeInTheDocument()
expect(screen.getByText('Weekly')).toBeInTheDocument()
expect(screen.getByText('Monthly')).toBeInTheDocument()
})
})
expect(screen.getByText('Hourly')).toBeInTheDocument();
expect(screen.getByText('Daily')).toBeInTheDocument();
expect(screen.getByText('Weekly')).toBeInTheDocument();
expect(screen.getByText('Monthly')).toBeInTheDocument();
});
});
// BKP-012: Weekly interval shows day-of-week picker
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
const user = userEvent.setup()
const user = userEvent.setup();
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
)
render(<BackupPanel />)
})
)
);
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument()
})
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
await user.click(screen.getByText('Weekly'))
expect(screen.getByText('Weekly')).toBeInTheDocument();
});
expect(screen.queryByText('Sun')).not.toBeInTheDocument();
await user.click(screen.getByText('Weekly'));
await waitFor(() => {
expect(screen.getByText('Sun')).toBeInTheDocument()
expect(screen.getByText('Mon')).toBeInTheDocument()
expect(screen.getByText('Sat')).toBeInTheDocument()
})
expect(screen.queryByText('Day of month')).not.toBeInTheDocument()
})
expect(screen.getByText('Sun')).toBeInTheDocument();
expect(screen.getByText('Mon')).toBeInTheDocument();
expect(screen.getByText('Sat')).toBeInTheDocument();
});
expect(screen.queryByText('Day of month')).not.toBeInTheDocument();
});
// BKP-013: Save auto-settings calls API and shows toast
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
const user = userEvent.setup()
const user = userEvent.setup();
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
})
),
http.put('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
}),
),
)
render(<><ToastContainer /><BackupPanel /></>)
})
)
);
render(
<>
<ToastContainer />
<BackupPanel />
</>
);
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument()
})
await user.click(screen.getByText('Weekly'))
expect(screen.getByText('Weekly')).toBeInTheDocument();
});
await user.click(screen.getByText('Weekly'));
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /^save$/i })
expect(saveBtn).not.toBeDisabled()
})
await user.click(screen.getByRole('button', { name: /^save$/i }))
const saveBtn = screen.getByRole('button', { name: /^save$/i });
expect(saveBtn).not.toBeDisabled();
});
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => {
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument()
})
})
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument();
});
});
// BKP-014: Save button disabled until settings changed
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
const user = userEvent.setup();
render(<BackupPanel />);
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
})
const saveBtn = screen.getByRole('button', { name: /^save$/i })
expect(saveBtn).toBeDisabled()
await user.click(getToggleButton())
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument();
});
const saveBtn = screen.getByRole('button', { name: /^save$/i });
expect(saveBtn).toBeDisabled();
await user.click(getToggleButton());
await waitFor(() => {
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
})
})
})
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled();
});
});
});
+295 -210
View File
@@ -1,27 +1,38 @@
import { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import CustomSelect from '../shared/CustomSelect'
import { getApiErrorMessage } from '../../types'
import {
AlertTriangle,
Check,
Clock,
Download,
HardDrive,
Plus,
RefreshCw,
RotateCcw,
Trash2,
Upload,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { backupApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { getApiErrorMessage } from '../../types';
import CustomSelect from '../shared/CustomSelect';
import { useToast } from '../shared/Toast';
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
]
];
const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
]
{ value: 0, labelKey: 'backup.keep.forever' },
];
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
@@ -31,193 +42,205 @@ const DAYS_OF_WEEK = [
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
]
];
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1);
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [serverTimezone, setServerTimezone] = useState('')
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null)
const toast = useToast()
const { t, language, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [backups, setBackups] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [restoringFile, setRestoringFile] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [autoSettings, setAutoSettings] = useState({
enabled: false,
interval: 'daily',
keep_days: 7,
hour: 2,
day_of_week: 0,
day_of_month: 1,
});
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false);
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false);
const [serverTimezone, setServerTimezone] = useState('');
const [restoreConfirm, setRestoreConfirm] = useState(null); // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null);
const toast = useToast();
const { t, language, locale } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const loadBackups = async () => {
setIsLoading(true)
setIsLoading(true);
try {
const data = await backupApi.list()
setBackups(data.backups || [])
const data = await backupApi.list();
setBackups(data.backups || []);
} catch {
toast.error(t('backup.toast.loadError'))
toast.error(t('backup.toast.loadError'));
} finally {
setIsLoading(false)
setIsLoading(false);
}
}
};
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
if (data.timezone) setServerTimezone(data.timezone)
const data = await backupApi.getAutoSettings();
setAutoSettings(data.settings);
if (data.timezone) setServerTimezone(data.timezone);
} catch {}
}
};
useEffect(() => { loadBackups(); loadAutoSettings() }, [])
useEffect(() => {
loadBackups();
loadAutoSettings();
}, []);
const handleCreate = async () => {
setIsCreating(true)
setIsCreating(true);
try {
await backupApi.create()
toast.success(t('backup.toast.created'))
await loadBackups()
await backupApi.create();
toast.success(t('backup.toast.created'));
await loadBackups();
} catch {
toast.error(t('backup.toast.createError'))
toast.error(t('backup.toast.createError'));
} finally {
setIsCreating(false)
setIsCreating(false);
}
}
};
const handleRestore = (filename) => {
setRestoreConfirm({ type: 'file', filename })
}
setRestoreConfirm({ type: 'file', filename });
};
const handleUploadRestore = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
e.target.value = ''
setRestoreConfirm({ type: 'upload', filename: file.name, file })
}
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
e.target.value = '';
setRestoreConfirm({ type: 'upload', filename: file.name, file });
};
const executeRestore = async () => {
if (!restoreConfirm) return
const { type, filename, file } = restoreConfirm
setRestoreConfirm(null)
if (!restoreConfirm) return;
const { type, filename, file } = restoreConfirm;
setRestoreConfirm(null);
if (type === 'file') {
setRestoringFile(filename)
setRestoringFile(filename);
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
await backupApi.restore(filename);
toast.success(t('backup.toast.restored'));
setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
setRestoringFile(null)
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')));
setRestoringFile(null);
}
} else {
setIsUploading(true)
setIsUploading(true);
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
await backupApi.uploadRestore(file);
toast.success(t('backup.toast.restored'));
setTimeout(() => window.location.reload(), 1500);
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
setIsUploading(false)
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')));
setIsUploading(false);
}
}
}
};
const handleDelete = async (filename) => {
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
if (!confirm(t('backup.confirm.delete', { name: filename }))) return;
try {
await backupApi.delete(filename)
toast.success(t('backup.toast.deleted'))
setBackups(prev => prev.filter(b => b.filename !== filename))
await backupApi.delete(filename);
toast.success(t('backup.toast.deleted'));
setBackups((prev) => prev.filter((b) => b.filename !== filename));
} catch {
toast.error(t('backup.toast.deleteError'))
toast.error(t('backup.toast.deleteError'));
}
}
};
const handleAutoSettingsChange = (key, value) => {
setAutoSettings(prev => ({ ...prev, [key]: value }))
setAutoSettingsDirty(true)
}
setAutoSettings((prev) => ({ ...prev, [key]: value }));
setAutoSettingsDirty(true);
};
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true)
setAutoSettingsSaving(true);
try {
const data = await backupApi.setAutoSettings(autoSettings)
setAutoSettings(data.settings)
setAutoSettingsDirty(false)
toast.success(t('backup.toast.settingsSaved'))
const data = await backupApi.setAutoSettings(autoSettings);
setAutoSettings(data.settings);
setAutoSettingsDirty(false);
toast.success(t('backup.toast.settingsSaved'));
} catch {
toast.error(t('backup.toast.settingsError'))
toast.error(t('backup.toast.settingsError'));
} finally {
setAutoSettingsSaving(false)
setAutoSettingsSaving(false);
}
}
};
const formatSize = (bytes) => {
if (!bytes) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
if (!bytes) return '-';
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const formatDate = (dateStr) => {
if (!dateStr) return '-'
if (!dateStr) return '-';
try {
const opts: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
}
if (serverTimezone) opts.timeZone = serverTimezone
return new Date(dateStr).toLocaleString(locale, opts)
} catch { return dateStr }
}
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
if (serverTimezone) opts.timeZone = serverTimezone;
return new Date(dateStr).toLocaleString(locale, opts);
} catch {
return dateStr;
}
};
const isAuto = (filename) => filename.startsWith('auto-backup-')
const isAuto = (filename) => filename.startsWith('auto-backup-');
return (
<div className="flex flex-col gap-6">
{/* Manual Backups */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div className="rounded-2xl border border-gray-200 bg-white p-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<HardDrive className="h-5 w-5 text-gray-400" />
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('backup.title')}
</h2>
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
{t('backup.subtitle')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadBackups}
disabled={isLoading}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100"
title={t('backup.refresh')}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{/* Upload & Restore */}
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={handleUploadRestore}
/>
<input ref={fileInputRef} type="file" accept=".zip" className="hidden" onChange={handleUploadRestore} />
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60"
title={isUploading ? t('backup.uploading') : t('backup.upload')}
>
{isUploading ? (
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Upload className="w-4 h-4" />
<Upload className="h-4 w-4" />
)}
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
</button>
@@ -225,13 +248,13 @@ export default function BackupPanel() {
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
className="flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900 disabled:opacity-60 dark:bg-slate-100 dark:text-slate-900 sm:px-4"
title={isCreating ? t('backup.creating') : t('backup.create')}
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<Plus className="w-4 h-4" />
<Plus className="h-4 w-4" />
)}
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
</button>
@@ -240,63 +263,69 @@ export default function BackupPanel() {
{isLoading && backups.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-400">
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
<div className="mr-2 h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-slate-700" />
{t('common.loading')}
</div>
) : backups.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
<div className="py-12 text-center text-gray-400">
<HardDrive className="mx-auto mb-3 h-10 w-10 opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
<button onClick={handleCreate} className="mt-4 text-sm text-slate-700 hover:underline">
{t('backup.createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{backups.map(backup => (
{backups.map((backup) => (
<div key={backup.filename} className="flex items-center gap-4 py-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
{isAuto(backup.filename)
? <RefreshCw className="w-4 h-4 text-blue-500" />
: <HardDrive className="w-4 h-4 text-gray-500" />
}
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gray-100">
{isAuto(backup.filename) ? (
<RefreshCw className="h-4 w-4 text-blue-500" />
) : (
<HardDrive className="h-4 w-4 text-gray-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
<p className="truncate text-sm font-medium text-gray-900">{backup.filename}</p>
{isAuto(backup.filename) && (
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
<span className="whitespace-nowrap rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-xs text-blue-600">
Auto
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<div className="mt-0.5 flex items-center gap-3">
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
onClick={() =>
backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))
}
className="flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
>
<Download className="w-3.5 h-3.5" />
<Download className="h-3.5 w-3.5" />
{t('backup.download')}
</button>
<button
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
className="flex items-center gap-1.5 rounded-lg border border-amber-200 px-3 py-1.5 text-xs text-amber-700 hover:bg-amber-50 disabled:opacity-60"
>
{restoringFile === backup.filename
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
: <RotateCcw className="w-3.5 h-3.5" />
}
{restoringFile === backup.filename ? (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-amber-400 border-t-transparent" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
{t('backup.restore')}
</button>
<button
onClick={() => handleDelete(backup.filename)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
@@ -306,29 +335,35 @@ export default function BackupPanel() {
</div>
{/* Auto-Backup Settings */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div className="rounded-2xl border border-gray-200 bg-white p-6">
<div className="mb-6 flex items-center gap-3">
<Clock className="h-5 w-5 text-gray-400" />
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('backup.auto.title')}
</h2>
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
{t('backup.auto.subtitle')}
</p>
</div>
</div>
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between gap-4 cursor-pointer">
<label className="flex cursor-pointer items-center justify-between gap-4">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
<p className="mt-0.5 text-xs text-gray-500">{t('backup.auto.enableHint')}</p>
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
className="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors"
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: autoSettings.enabled ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</label>
@@ -336,16 +371,16 @@ export default function BackupPanel() {
<>
{/* Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map(opt => (
{INTERVAL_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
autoSettings.interval === opt.value
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
@@ -357,25 +392,26 @@ export default function BackupPanel() {
{/* Hour picker (for daily, weekly, monthly) */}
{autoSettings.interval !== 'hourly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.hour')}</label>
<CustomSelect
value={String(autoSettings.hour)}
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
onChange={(v) => handleAutoSettingsChange('hour', parseInt(v, 10))}
size="sm"
options={HOURS.map(h => {
let label: string
options={HOURS.map((h) => {
let label: string;
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
label = `${h12}:00 ${period}`
const period = h >= 12 ? 'PM' : 'AM';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
label = `${h12}:00 ${period}`;
} else {
label = `${String(h).padStart(2, '0')}:00`
label = `${String(h).padStart(2, '0')}:00`;
}
return { value: String(h), label }
return { value: String(h), label };
})}
/>
<p className="text-xs text-gray-400 mt-1">
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
<p className="mt-1 text-xs text-gray-400">
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}
{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
</p>
</div>
)}
@@ -383,16 +419,16 @@ export default function BackupPanel() {
{/* Day of week (for weekly) */}
{autoSettings.interval === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map(opt => (
{DAYS_OF_WEEK.map((opt) => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
autoSettings.day_of_week === opt.value
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
@@ -405,29 +441,29 @@ export default function BackupPanel() {
{/* Day of month (for monthly) */}
{autoSettings.interval === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.dayOfMonth')}</label>
<CustomSelect
value={String(autoSettings.day_of_month)}
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
onChange={(v) => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
size="sm"
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
options={DAYS_OF_MONTH.map((d) => ({ value: String(d), label: String(d) }))}
/>
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
<p className="mt-1 text-xs text-gray-400">{t('backup.auto.dayOfMonthHint')}</p>
</div>
)}
{/* Keep duration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
<label className="mb-2 block text-sm font-medium text-gray-700">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map(opt => (
{KEEP_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
autoSettings.keep_days === opt.value
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
? 'border-slate-700 bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
@@ -439,16 +475,17 @@ export default function BackupPanel() {
)}
{/* Save button */}
<div className="flex justify-end pt-2 border-t border-gray-100">
<div className="flex justify-end border-t border-gray-100 pt-2">
<button
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
className="flex items-center gap-2 rounded-lg bg-slate-900 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-900 disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900"
>
{autoSettingsSaving
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Check className="w-4 h-4" />
}
{autoSettingsSaving ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<Check className="h-4 w-4" />
)}
{autoSettingsSaving ? t('common.saving') : t('common.save')}
</button>
</div>
@@ -458,17 +495,46 @@ export default function BackupPanel() {
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
onClick={() => setRestoreConfirm(null)}
>
<div
onClick={e => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
className="border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
style={{
background: 'linear-gradient(135deg, #dc2626, #b91c1c)',
padding: '20px 24px',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
@@ -487,8 +553,9 @@ export default function BackupPanel() {
{t('backup.restoreWarning')}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
<div
style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="border border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300"
>
{t('backup.restoreTip')}
</div>
@@ -498,16 +565,34 @@ export default function BackupPanel() {
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
className="text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
style={{
padding: '9px 20px',
borderRadius: 10,
fontSize: 13,
fontWeight: 600,
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button
onClick={executeRestore}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
style={{
padding: '9px 20px',
borderRadius: 10,
fontSize: 13,
fontWeight: 600,
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
background: '#dc2626',
color: 'white',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#b91c1c')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#dc2626')}
>
{t('backup.restoreConfirm')}
</button>
@@ -516,5 +601,5 @@ export default function BackupPanel() {
</div>
)}
</div>
)
);
}
@@ -1,21 +1,17 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-012
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildCategory, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildCategory } from '../../../tests/helpers/factories';
import CategoryManager from './CategoryManager';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import CategoryManager from './CategoryManager';
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [] })
),
);
server.use(http.get('/api/categories', () => HttpResponse.json({ categories: [] })));
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
});
@@ -52,10 +48,7 @@ describe('CategoryManager', () => {
server.use(
http.get('/api/categories', () =>
HttpResponse.json({
categories: [
buildCategory({ name: 'Museum' }),
buildCategory({ name: 'Restaurant' }),
],
categories: [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Restaurant' })],
})
)
);
@@ -70,13 +63,18 @@ describe('CategoryManager', () => {
server.use(
http.post('/api/categories', async ({ request }) => {
postCalled = true;
const body = await request.json() as Record<string, unknown>;
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
category: buildCategory({ name: String(body.name) }),
});
})
);
render(<><ToastContainer /><CategoryManager /></>);
render(
<>
<ToastContainer />
<CategoryManager />
</>
);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
const nameInput = screen.getByPlaceholderText('Category name');
@@ -88,9 +86,7 @@ describe('CategoryManager', () => {
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
)
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] }))
);
render(<CategoryManager />);
await screen.findByText('Hotels');
@@ -98,7 +94,7 @@ describe('CategoryManager', () => {
const buttons = screen.getAllByRole('button');
// Buttons: [New Category, ...action buttons for the category]
// The edit button is the first action button in the category row (Edit2 icon)
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
await user.click(actionBtns[0]);
// Name input pre-filled with category name
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
@@ -108,20 +104,23 @@ describe('CategoryManager', () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
),
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
http.delete('/api/categories/9', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<><ToastContainer /><CategoryManager /></>);
render(
<>
<ToastContainer />
<CategoryManager />
</>
);
await screen.findByText('Parks');
// Delete button is icon-only (Trash2, no title) — find the second action button
const buttons = screen.getAllByRole('button');
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
const actionBtns = buttons.filter((b) => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
await waitFor(() => expect(deleteCalled).toBe(true));
vi.restoreAllMocks();
+157 -118
View File
@@ -1,144 +1,160 @@
import { useState, useEffect, useRef } from 'react'
import { categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import { getApiErrorMessage } from '../../types'
import { Edit2, Pipette, Plus, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { categoriesApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { getApiErrorMessage } from '../../types';
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons';
import { useToast } from '../shared/Toast';
const PRESET_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
'#6b7280', '#1f2937',
]
'#6366f1',
'#8b5cf6',
'#ec4899',
'#ef4444',
'#f97316',
'#f59e0b',
'#10b981',
'#06b6d4',
'#3b82f6',
'#84cc16',
'#6b7280',
'#1f2937',
];
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP);
export default function CategoryManager() {
const [categories, setCategories] = useState([])
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState(null)
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const colorInputRef = useRef(null)
const toast = useToast()
const { t } = useTranslation()
const [categories, setCategories] = useState([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' });
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const colorInputRef = useRef(null);
const toast = useToast();
const { t } = useTranslation();
useEffect(() => { loadCategories() }, [])
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
setIsLoading(true)
setIsLoading(true);
try {
const data = await categoriesApi.list()
setCategories(data.categories || [])
const data = await categoriesApi.list();
setCategories(data.categories || []);
} catch (err: unknown) {
toast.error(t('categories.toast.loadError'))
toast.error(t('categories.toast.loadError'));
} finally {
setIsLoading(false)
setIsLoading(false);
}
}
};
const handleStartEdit = (cat) => {
setEditingId(cat.id)
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
setShowForm(false)
}
setEditingId(cat.id);
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' });
setShowForm(false);
};
const handleStartCreate = () => {
setEditingId(null)
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
setShowForm(true)
}
setEditingId(null);
setForm({ name: '', color: '#6366f1', icon: 'MapPin' });
setShowForm(true);
};
const handleCancel = () => {
setShowForm(false)
setEditingId(null)
}
setShowForm(false);
setEditingId(null);
};
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
setIsSaving(true)
if (!form.name.trim()) {
toast.error(t('categories.toast.nameRequired'));
return;
}
setIsSaving(true);
try {
if (editingId) {
const result = await categoriesApi.update(editingId, form)
setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
setEditingId(null)
toast.success(t('categories.toast.updated'))
const result = await categoriesApi.update(editingId, form);
setCategories((prev) => prev.map((c) => (c.id === editingId ? result.category : c)));
setEditingId(null);
toast.success(t('categories.toast.updated'));
} else {
const result = await categoriesApi.create(form)
setCategories(prev => [...prev, result.category])
setShowForm(false)
toast.success(t('categories.toast.created'))
const result = await categoriesApi.create(form);
setCategories((prev) => [...prev, result.category]);
setShowForm(false);
toast.success(t('categories.toast.created'));
}
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
setForm({ name: '', color: '#6366f1', icon: 'MapPin' });
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')));
} finally {
setIsSaving(false)
setIsSaving(false);
}
}
};
const handleDelete = async (id) => {
if (!confirm(t('categories.confirm.delete'))) return
if (!confirm(t('categories.confirm.delete'))) return;
try {
await categoriesApi.delete(id)
setCategories(prev => prev.filter(c => c.id !== id))
toast.success(t('categories.toast.deleted'))
await categoriesApi.delete(id);
setCategories((prev) => prev.filter((c) => c.id !== id));
toast.success(t('categories.toast.deleted'));
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')));
}
}
};
const isPresetColor = PRESET_COLORS.includes(form.color)
const PreviewIcon = getCategoryIcon(form.icon)
const isPresetColor = PRESET_COLORS.includes(form.color);
const PreviewIcon = getCategoryIcon(form.icon);
const categoryForm = (
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-200">
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<input
type="text"
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('categories.namePlaceholder')}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
autoFocus
/>
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">{t('categories.icon')}</label>
<label className="mb-2 block text-xs font-medium text-gray-600">{t('categories.icon')}</label>
<div className="max-h-48 overflow-y-auto">
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
{ICON_NAMES.map(name => {
const Icon = CATEGORY_ICON_MAP[name]
const isSelected = form.icon === name
{ICON_NAMES.map((name) => {
const Icon = CATEGORY_ICON_MAP[name];
const isSelected = form.icon === name;
return (
<button
key={name}
type="button"
title={ICON_LABELS[name] || name}
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
isSelected
? 'ring-2 ring-offset-1 ring-slate-700'
: 'hover:bg-gray-200'
onClick={() => setForm((prev) => ({ ...prev, icon: name }))}
className={`flex h-9 w-9 items-center justify-center rounded-lg transition-all ${
isSelected ? 'ring-2 ring-slate-700 ring-offset-1' : 'hover:bg-gray-200'
}`}
style={{ background: isSelected ? `${form.color}18` : undefined }}
>
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
</button>
)
);
})}
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1.5">{t('categories.color')}</label>
<div className="flex items-center gap-2 flex-wrap">
{PRESET_COLORS.map(color => (
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
className={`w-7 h-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
style={{ backgroundColor: color }} />
<label className="mb-1.5 block text-xs font-medium text-gray-600">{t('categories.color')}</label>
<div className="flex flex-wrap items-center gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setForm((prev) => ({ ...prev, color }))}
className={`h-7 w-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'scale-110 ring-2 ring-gray-400 ring-offset-2' : ''}`}
style={{ backgroundColor: color }}
/>
))}
{/* Custom color button */}
@@ -146,57 +162,72 @@ export default function CategoryManager() {
ref={colorInputRef}
type="color"
value={form.color}
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
onChange={(e) => setForm((prev) => ({ ...prev, color: e.target.value }))}
className="sr-only"
/>
<button
type="button"
title={t('categories.customColor')}
onClick={() => colorInputRef.current?.click()}
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 transition-transform hover:scale-110 ${
className={`flex h-7 w-7 items-center justify-center rounded-full border-2 transition-transform hover:scale-110 ${
!isPresetColor
? 'ring-2 ring-offset-2 ring-gray-400 scale-110 border-transparent'
? 'scale-110 border-transparent ring-2 ring-gray-400 ring-offset-2'
: 'border-dashed border-gray-300 hover:border-gray-400'
}`}
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
>
{isPresetColor && <Pipette className="w-3 h-3 text-gray-400" />}
{isPresetColor && <Pipette className="h-3 w-3 text-gray-400" />}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
<span className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1 rounded-full font-medium"
style={{ backgroundColor: `${form.color}20`, color: form.color }}>
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium"
style={{ backgroundColor: `${form.color}20`, color: form.color }}
>
<PreviewIcon size={14} strokeWidth={1.8} />
{form.name || t('categories.defaultName')}
</span>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={handleCancel}
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50">
<button
type="button"
onClick={handleCancel}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={isSaving || !form.name.trim()}
className="px-4 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium">
<button
type="button"
onClick={handleSave}
disabled={isSaving || !form.name.trim()}
className="rounded-lg bg-slate-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-60"
>
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
</button>
</div>
</div>
)
);
return (
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div className="rounded-2xl border border-gray-200 bg-white p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('categories.title')}
</h2>
<p className="mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
{t('categories.subtitle')}
</p>
</div>
<button onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
<button
onClick={handleStartCreate}
className="flex items-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700 sm:px-4"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t('categories.new')}</span>
</button>
</div>
@@ -205,52 +236,60 @@ export default function CategoryManager() {
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-400">
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-600 rounded-full animate-spin" />
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-slate-600" />
</div>
) : categories.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="py-8 text-center text-gray-400">
<p className="text-sm">{t('categories.empty')}</p>
</div>
) : (
<div className="space-y-2">
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon)
{categories.map((cat) => {
const Icon = getCategoryIcon(cat.icon);
return (
<div key={cat.id}>
{editingId === cat.id ? (
<div className="mb-2">{categoryForm}</div>
) : (
<div className="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:border-gray-200 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: `${cat.color}20` }}>
<div className="group flex items-center gap-3 rounded-xl border border-gray-100 p-3 hover:border-gray-200">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
style={{ backgroundColor: `${cat.color}20` }}
>
<Icon size={18} strokeWidth={1.8} color={cat.color} />
</div>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 text-sm">{cat.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}>
<span className="text-sm font-medium text-gray-900">{cat.name}</span>
<span
className="rounded-full px-2 py-0.5 text-xs"
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}
>
{cat.color}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleStartEdit(cat)}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
<Edit2 className="w-4 h-4" />
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => handleStartEdit(cat)}
className="rounded-lg p-1.5 text-gray-400 hover:bg-slate-100 hover:text-slate-700"
>
<Edit2 className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(cat.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
<Trash2 className="w-4 h-4" />
<button
onClick={() => handleDelete(cat.id)}
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
)
);
})}
</div>
)}
</div>
)
);
}
@@ -0,0 +1,357 @@
import { Settings2 } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import type { Place } from '../../types';
import { MapView } from '../Map/MapView';
import Section from '../Settings/Section';
import CustomSelect from '../shared/CustomSelect';
import { useToast } from '../shared/Toast';
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
];
type Defaults = {
temperature_unit?: string;
dark_mode?: string | boolean;
time_format?: string;
route_calculation?: boolean;
blur_booking_codes?: boolean;
map_tile_url?: string;
};
function OptionRow({ label, hint, children }: { label: React.ReactNode; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-2 block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{hint && (
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
{hint}
</p>
)}
<div className="flex flex-wrap gap-3">{children}</div>
</div>
);
}
function OptionButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '10px 20px',
borderRadius: 10,
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: 14,
fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{children}
</button>
);
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t } = useTranslation();
const toast = useToast();
const [defaults, setDefaults] = useState<Defaults>({});
const [loaded, setLoaded] = useState(false);
const [mapTileUrl, setMapTileUrl] = useState('');
useEffect(() => {
adminApi
.getDefaultUserSettings()
.then((data: Defaults) => {
setDefaults(data);
setMapTileUrl(data.map_tile_url || '');
setLoaded(true);
})
.catch(() => setLoaded(true));
}, []);
const save = async (patch: Partial<Defaults>) => {
try {
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>);
setDefaults(updated);
toast.success(t('admin.defaultSettings.saved'));
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'));
}
};
const reset = async (key: keyof Defaults) => {
try {
const updated = await adminApi.updateDefaultUserSettings({ [key]: null });
setDefaults(updated);
if (key === 'map_tile_url') setMapTileUrl('');
toast.success(t('admin.defaultSettings.reset'));
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.error'));
}
};
const isSet = (key: keyof Defaults) => defaults[key] !== undefined;
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
onClick={() => reset(field)}
className="ml-2 text-xs"
style={{
color: 'var(--text-faint)',
textDecoration: 'underline',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null;
const mapPreviewPlaces = useMemo(
(): Place[] => [
{
id: 1,
trip_id: 1,
name: 'Preview center',
description: null,
notes: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
duration_minutes: null,
transport_mode: null,
website: null,
phone: null,
created_at: Date(),
},
],
[]
);
if (!loaded) {
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p>;
}
const darkMode = defaults.dark_mode;
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
{t('admin.defaultSettings.description')}
</p>
{/* Color Mode */}
<OptionRow
label={
<>
{t('settings.colorMode')} <ResetButton field="dark_mode" />
</>
}
>
{(
[
{ value: 'light', label: t('settings.light') },
{ value: 'dark', label: t('settings.dark') },
{ value: 'auto', label: t('settings.auto') },
] as const
).map((opt) => (
<OptionButton
key={opt.value}
active={
darkMode === opt.value ||
(opt.value === 'light' && darkMode === false) ||
(opt.value === 'dark' && darkMode === true)
}
onClick={() => save({ dark_mode: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Temperature */}
<OptionRow
label={
<>
{t('settings.temperature')} <ResetButton field="temperature_unit" />
</>
}
>
{(
[
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
] as const
).map((opt) => (
<OptionButton
key={opt.value}
active={defaults.temperature_unit === opt.value}
onClick={() => save({ temperature_unit: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Time Format */}
<OptionRow
label={
<>
{t('settings.timeFormat')} <ResetButton field="time_format" />
</>
}
>
{(
[
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
] as const
).map((opt) => (
<OptionButton
key={opt.value}
active={defaults.time_format === opt.value}
onClick={() => save({ time_format: opt.value })}
>
{opt.label}
</OptionButton>
))}
</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" />
</>
}
>
{(
[
{ 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.blur_booking_codes === opt.value}
onClick={() => save({ blur_booking_codes: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Map Tile URL */}
<div>
<label className="mb-1.5 block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('settings.mapTemplate')}
<ResetButton field="map_tile_url" />
</label>
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => {
if (value) {
setMapTileUrl(value);
save({ map_tile_url: value });
}
}}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
onBlur={() => save({ map_tile_url: mapTileUrl })}
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-slate-400"
/>
<p className="mt-1 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.mapDefaultHint')}
</p>
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
places: mapPreviewPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
center: [48.8566, 2.3522],
zoom: 10,
tileUrl: mapTileUrl,
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})}
</div>
</div>
</Section>
);
}
@@ -1,9 +1,9 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
@@ -22,12 +22,22 @@ afterEach(() => {
describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
// Wait for async data to populate conditional sections
await screen.findByText('Trip-Scoped Events');
await screen.findByText('User-Scoped Events');
@@ -36,37 +46,52 @@ describe('DevNotificationsPanel', () => {
});
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const options = Array.from(tripSelect.querySelectorAll('option'));
const labels = options.map(o => o.textContent);
const labels = options.map((o) => o.textContent);
expect(labels).toContain('Paris Adventure');
expect(labels).toContain('Tokyo Trip');
});
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('User-Scoped Events');
const selects = screen.getAllByRole('combobox');
// Second combobox is the user selector (first is trip selector)
const userSelect = selects[1];
const options = Array.from(userSelect.querySelectorAll('option'));
const labels = options.map(o => o.textContent ?? '');
expect(labels.some(l => l.includes('admin'))).toBe(true);
expect(labels.some(l => l.includes('alice'))).toBe(true);
const labels = options.map((o) => o.textContent ?? '');
expect(labels.some((l) => l.includes('admin'))).toBe(true);
expect(labels.some((l) => l.includes('alice'))).toBe(true);
});
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ok: true });
}),
})
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
@@ -78,13 +103,14 @@ describe('DevNotificationsPanel', () => {
});
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ ok: true }),
),
);
server.use(http.post('/api/admin/dev/test-notification', () => HttpResponse.json({ ok: true })));
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Sent: simple-me');
@@ -95,10 +121,15 @@ describe('DevNotificationsPanel', () => {
http.post('/api/admin/dev/test-notification', async () => {
await new Promise(() => {}); // never resolves — simulates in-flight
return HttpResponse.json({ ok: true });
}),
})
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
// Fire the click but do not await — handler never resolves so sending stays true
@@ -106,18 +137,23 @@ describe('DevNotificationsPanel', () => {
await waitFor(() => {
const buttons = screen.getAllByRole('button');
buttons.forEach(btn => expect(btn).toBeDisabled());
buttons.forEach((btn) => expect(btn).toBeDisabled());
});
});
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
),
HttpResponse.json({ message: 'Server error' }, { status: 500 })
)
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText(/failed|error/i);
@@ -127,18 +163,21 @@ describe('DevNotificationsPanel', () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ok: true });
}),
})
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
o => o.textContent === 'Tokyo Trip',
)!;
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find((o) => o.textContent === 'Tokyo Trip')!;
const tokyoId = Number(tokyoOption.value);
await user.selectOptions(tripSelect, 'Tokyo Trip');
@@ -149,10 +188,13 @@ describe('DevNotificationsPanel', () => {
});
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
render(
<>
<ToastContainer />
<DevNotificationsPanel />
</>
);
render(<><ToastContainer /><DevNotificationsPanel /></>);
// Wait for user data to confirm async effects have settled
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
@@ -1,122 +1,170 @@
import React, { useState, useEffect } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import {
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
Download, MapPin,
} from 'lucide-react'
Bell,
Calendar,
CheckCircle,
Clock,
Download,
Image,
MapPin,
MessageSquare,
Navigation,
Tag,
UserPlus,
Zap,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { adminApi, tripsApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { useToast } from '../shared/Toast';
interface Trip {
id: number
title: string
id: number;
title: string;
}
interface AppUser {
id: number
username: string
email: string
id: number;
username: string;
email: string;
}
export default function DevNotificationsPanel(): React.ReactElement {
const toast = useToast()
const user = useAuthStore(s => s.user)
const [sending, setSending] = useState<string | null>(null)
const [trips, setTrips] = useState<Trip[]>([])
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
const [users, setUsers] = useState<AppUser[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const toast = useToast();
const user = useAuthStore((s) => s.user);
const [sending, setSending] = useState<string | null>(null);
const [trips, setTrips] = useState<Trip[]>([]);
const [selectedTripId, setSelectedTripId] = useState<number | null>(null);
const [users, setUsers] = useState<AppUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
useEffect(() => {
tripsApi.list().then(data => {
const list = (data.trips || data || []) as Trip[]
setTrips(list)
if (list.length > 0) setSelectedTripId(list[0].id)
}).catch(() => {})
adminApi.users().then(data => {
const list = (data.users || data || []) as AppUser[]
setUsers(list)
if (list.length > 0) setSelectedUserId(list[0].id)
}).catch(() => {})
}, [])
tripsApi
.list()
.then((data) => {
const list = (data.trips || data || []) as Trip[];
setTrips(list);
if (list.length > 0) setSelectedTripId(list[0].id);
})
.catch(() => {});
adminApi
.users()
.then((data) => {
const list = (data.users || data || []) as AppUser[];
setUsers(list);
if (list.length > 0) setSelectedUserId(list[0].id);
})
.catch(() => {});
}, []);
const fire = async (label: string, payload: Record<string, unknown>) => {
setSending(label)
setSending(label);
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
await adminApi.sendTestNotification(payload);
toast.success(`Sent: ${label}`);
} catch (err: any) {
toast.error(err.message || 'Failed')
toast.error(err.message || 'Failed');
} finally {
setSending(null)
setSending(null);
}
}
};
const selectedTrip = trips.find(t => t.id === selectedTripId)
const selectedUser = users.find(u => u.id === selectedUserId)
const username = user?.username || 'Admin'
const tripTitle = selectedTrip?.title || 'Test Trip'
const selectedTrip = trips.find((t) => t.id === selectedTripId);
const selectedUser = users.find((u) => u.id === selectedUserId);
const username = user?.username || 'Admin';
const tripTitle = selectedTrip?.title || 'Test Trip';
// ── Helpers ──────────────────────────────────────────────────────────────
const Btn = ({
id, label, sub, icon: Icon, color, onClick,
id,
label,
sub,
icon: Icon,
color,
onClick,
}: {
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
id: string;
label: string;
sub: string;
icon: React.ElementType;
color: string;
onClick: () => void;
}) => (
<button
onClick={onClick}
disabled={sending !== null}
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
className="flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-card)';
}}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: `${color}20`, color }}>
<Icon className="w-4 h-4" />
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg"
style={{ background: `${color}20`, color }}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{label}
</p>
<p className="truncate text-xs" style={{ color: 'var(--text-faint)' }}>
{sub}
</p>
</div>
{sending === id && (
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
<div className="h-4 w-4 flex-shrink-0 animate-spin rounded-full border-2 border-slate-200 border-t-indigo-500" />
)}
</button>
)
);
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
)
<h3 className="mb-3 text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
{children}
</h3>
);
const TripSelector = () => (
<select
value={selectedTripId ?? ''}
onChange={e => setSelectedTripId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
onChange={(e) => setSelectedTripId(Number(e.target.value))}
className="mb-3 w-full rounded-lg border px-3 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
{trips.map((trip) => (
<option key={trip.id} value={trip.id}>
{trip.title}
</option>
))}
</select>
)
);
const UserSelector = () => (
<select
value={selectedUserId ?? ''}
onChange={e => setSelectedUserId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
onChange={(e) => setSelectedUserId(Number(e.target.value))}
className="mb-3 w-full rounded-lg border px-3 py-2 text-sm"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.email})
</option>
))}
</select>
)
);
return (
<div className="space-y-8">
<div className="flex items-center gap-2">
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
<div
className="rounded px-2 py-0.5 font-mono text-xs font-bold"
style={{ background: '#fbbf24', color: '#000' }}
>
DEV ONLY
</div>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
@@ -127,46 +175,74 @@ export default function DevNotificationsPanel(): React.ReactElement {
{/* ── Type Testing ─────────────────────────────────────────────────── */}
<div>
<SectionTitle>Type Testing</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Test how each in-app notification type renders, sent to yourself.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
onClick={() => fire('simple-me', {
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
})}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id="simple-me"
label="Simple → Me"
sub="test_simple · user"
icon={Bell}
color="#6366f1"
onClick={() =>
fire('simple-me', {
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
})
}
/>
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
onClick={() => fire('boolean-me', {
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
})}
<Btn
id="boolean-me"
label="Boolean → Me"
sub="test_boolean · user"
icon={CheckCircle}
color="#10b981"
onClick={() =>
fire('boolean-me', {
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
})
}
/>
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
onClick={() => fire('navigate-me', {
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
})}
<Btn
id="navigate-me"
label="Navigate → Me"
sub="test_navigate · user"
icon={Navigation}
color="#f59e0b"
onClick={() =>
fire('navigate-me', {
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
})
}
/>
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
onClick={() => fire('simple-admins', {
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
})}
<Btn
id="simple-admins"
label="Simple → All Admins"
sub="test_simple · admin"
icon={Zap}
color="#ef4444"
onClick={() =>
fire('simple-admins', {
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
})
}
/>
</div>
</div>
@@ -175,50 +251,101 @@ export default function DevNotificationsPanel(): React.ReactElement {
{trips.length > 0 && (
<div>
<SectionTitle>Trip-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Fires each trip event to all members of the selected trip (excluding yourself).
</p>
<TripSelector />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
onClick={() => selectedTripId && fire('booking_change', {
event: 'booking_change',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
})}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id="booking_change"
label="booking_change"
sub="navigate · trip"
icon={Calendar}
color="#6366f1"
onClick={() =>
selectedTripId &&
fire('booking_change', {
event: 'booking_change',
scope: 'trip',
targetId: selectedTripId,
params: {
actor: username,
trip: tripTitle,
booking: 'Test Hotel',
type: 'hotel',
tripId: String(selectedTripId),
},
})
}
/>
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
onClick={() => selectedTripId && fire('trip_reminder', {
event: 'trip_reminder',
scope: 'trip',
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
})}
<Btn
id="trip_reminder"
label="trip_reminder"
sub="navigate · trip"
icon={Clock}
color="#10b981"
onClick={() =>
selectedTripId &&
fire('trip_reminder', {
event: 'trip_reminder',
scope: 'trip',
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
})
}
/>
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
onClick={() => selectedTripId && fire('photos_shared', {
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
})}
<Btn
id="photos_shared"
label="photos_shared"
sub="navigate · trip"
icon={Image}
color="#f59e0b"
onClick={() =>
selectedTripId &&
fire('photos_shared', {
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
})
}
/>
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
onClick={() => selectedTripId && fire('collab_message', {
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
})}
<Btn
id="collab_message"
label="collab_message"
sub="navigate · trip"
icon={MessageSquare}
color="#8b5cf6"
onClick={() =>
selectedTripId &&
fire('collab_message', {
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: {
actor: username,
trip: tripTitle,
preview: 'This is a test message preview.',
tripId: String(selectedTripId),
},
})
}
/>
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
onClick={() => selectedTripId && fire('packing_tagged', {
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
})}
<Btn
id="packing_tagged"
label="packing_tagged"
sub="navigate · trip"
icon={Tag}
color="#ec4899"
onClick={() =>
selectedTripId &&
fire('packing_tagged', {
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
})
}
/>
</div>
</div>
@@ -228,23 +355,31 @@ export default function DevNotificationsPanel(): React.ReactElement {
{users.length > 0 && (
<div>
<SectionTitle>User-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Fires each user event to the selected recipient.
</p>
<UserSelector />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id={`trip_invite-${selectedUserId}`}
label="trip_invite"
sub="navigate · user"
icon={UserPlus}
color="#06b6d4"
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
event: 'trip_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
})}
onClick={() =>
selectedUserId &&
fire(`trip_invite-${selectedUserId}`, {
event: 'trip_invite',
scope: 'user',
targetId: selectedUserId,
params: {
actor: username,
trip: tripTitle,
invitee: selectedUser?.email || '',
tripId: String(selectedTripId ?? 0),
},
})
}
/>
<Btn
id={`vacay_invite-${selectedUserId}`}
@@ -252,12 +387,15 @@ export default function DevNotificationsPanel(): React.ReactElement {
sub="navigate · user"
icon={MapPin}
color="#f97316"
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
event: 'vacay_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, planId: '1' },
})}
onClick={() =>
selectedUserId &&
fire(`vacay_invite-${selectedUserId}`, {
event: 'vacay_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, planId: '1' },
})
}
/>
</div>
</div>
@@ -266,20 +404,27 @@ export default function DevNotificationsPanel(): React.ReactElement {
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
<div>
<SectionTitle>Admin-Scoped Events</SectionTitle>
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
<p className="mb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
Fires to all admin users.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
onClick={() => fire('version_available', {
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
})}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Btn
id="version_available"
label="version_available"
sub="navigate · admin"
icon={Download}
color="#64748b"
onClick={() =>
fire('version_available', {
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
})
}
/>
</div>
</div>
</div>
)
);
}
@@ -1,8 +1,8 @@
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import GitHubPanel from './GitHubPanel';
@@ -21,18 +21,12 @@ function buildRelease(overrides = {}) {
};
}
const PAGE_1 = Array.from({ length: 10 }, (_, i) =>
buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }),
);
const PAGE_2 = Array.from({ length: 5 }, (_, i) =>
buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }),
);
const PAGE_1 = Array.from({ length: 10 }, (_, i) => buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }));
const PAGE_2 = Array.from({ length: 5 }, (_, i) => buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }));
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([])));
});
afterEach(() => {
@@ -42,9 +36,7 @@ afterEach(() => {
describe('GitHubPanel', () => {
it('FE-ADMIN-GH-001: support link cards always render', async () => {
render(<GitHubPanel />);
await waitFor(() =>
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
);
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
expect(screen.getByText('Discord')).toBeInTheDocument();
@@ -68,7 +60,7 @@ describe('GitHubPanel', () => {
expect(bmc).toHaveAttribute('rel', 'noopener noreferrer');
const discord = screen.getByText('Discord').closest('a')!;
expect(discord).toHaveAttribute('href', 'https://discord.gg/nSdKaXgN');
expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
expect(discord).toHaveAttribute('target', '_blank');
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
});
@@ -78,7 +70,7 @@ describe('GitHubPanel', () => {
http.get('/api/admin/github-releases', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json([]);
}),
})
);
render(<GitHubPanel />);
// The Loader2 spinner is rendered while loading=true
@@ -89,8 +81,8 @@ describe('GitHubPanel', () => {
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
server.use(
http.get('/api/admin/github-releases', () =>
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
),
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 })
)
);
render(<GitHubPanel />);
await screen.findByText('Failed to load releases');
@@ -101,9 +93,7 @@ describe('GitHubPanel', () => {
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
render(<GitHubPanel />);
await screen.findByText('v1.0.0');
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
@@ -112,16 +102,16 @@ describe('GitHubPanel', () => {
expect(authorLabels.length).toBeGreaterThan(0);
// Some date should be visible (non-empty)
const dateEls = document.querySelectorAll('[class*="text-"]');
const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/));
const dateTexts = Array.from(dateEls)
.map((el) => el.textContent)
.filter((t) => t && t.match(/\d{4}/));
expect(dateTexts.length).toBeGreaterThan(0);
});
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])));
render(<GitHubPanel />);
await screen.findByText('v2.0.0');
const latestBadges = screen.getAllByText('Latest');
@@ -130,9 +120,7 @@ describe('GitHubPanel', () => {
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
render(<GitHubPanel isPrerelease={true} />);
await screen.findByText('v3.0.0-beta.1');
expect(screen.getByText('Pre-release')).toBeInTheDocument();
@@ -144,9 +132,7 @@ describe('GitHubPanel', () => {
tag_name: 'v1.5.0',
body: '- Fixed bug\n- Another fix',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.5.0');
@@ -164,9 +150,7 @@ describe('GitHubPanel', () => {
// Collapse
await user.click(screen.getByText('Hide details'));
await waitFor(() =>
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(),
);
await waitFor(() => expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument());
expect(screen.getByText('Show details')).toBeInTheDocument();
});
@@ -176,9 +160,7 @@ describe('GitHubPanel', () => {
tag_name: 'v1.6.0',
body: '- list item\n- **bold text**\n- `inline code`',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.6.0');
@@ -201,18 +183,14 @@ describe('GitHubPanel', () => {
});
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)));
render(<GitHubPanel />);
await screen.findByText(`v1.0.0`);
expect(screen.getByText('Load more')).toBeInTheDocument();
});
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)));
render(<GitHubPanel />);
await screen.findByText('v0.0.0');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
@@ -224,9 +202,7 @@ describe('GitHubPanel', () => {
tag_name: 'v1.7.0',
body: 'This is a plain paragraph without any markdown syntax.',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.7.0');
@@ -240,9 +216,7 @@ describe('GitHubPanel', () => {
tag_name: 'v1.8.0',
body: '- [click here](https://example.com)',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.8.0');
@@ -257,9 +231,7 @@ describe('GitHubPanel', () => {
tag_name: 'v1.9.0',
body: '- [evil](javascript:alert(1))',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
server.use(http.get('/api/admin/github-releases', () => HttpResponse.json([r])));
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.9.0');
@@ -311,7 +283,7 @@ describe('GitHubPanel', () => {
return HttpResponse.json(PAGE_2);
}
return HttpResponse.json(PAGE_1);
}),
})
);
const user = userEvent.setup();
render(<GitHubPanel />);
+412 -224
View File
@@ -1,146 +1,200 @@
import { useState, useEffect } from 'react'
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import apiClient from '../../api/client'
import {
BookOpen,
Bug,
Calendar,
ChevronDown,
ChevronUp,
Coffee,
ExternalLink,
Heart,
Lightbulb,
Loader2,
Tag,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import apiClient from '../../api/client';
import { getLocaleForLanguage, useTranslation } from '../../i18n';
const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10
const REPO = 'mauriceboe/TREK';
const PER_PAGE = 10;
interface GithubRelease {
id: number
prerelease: boolean
[key: string]: unknown
id: number;
prerelease: boolean;
[key: string]: unknown;
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation()
const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const { t, language } = useTranslation();
const [releases, setReleases] = useState<GithubRelease[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const fetchReleases = async (pageNum = 1, append = false) => {
try {
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
const data = Array.isArray(res.data) ? res.data : []
setReleases(prev => append ? [...prev, ...data] : data)
setHasMore(data.length === PER_PAGE)
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
const data = Array.isArray(res.data) ? res.data : [];
setReleases((prev) => (append ? [...prev, ...data] : data));
setHasMore(data.length === PER_PAGE);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error')
setError(err instanceof Error ? err.message : 'Unknown error');
}
}
};
useEffect(() => {
setLoading(true)
fetchReleases(1).finally(() => setLoading(false))
}, [])
setLoading(true);
fetchReleases(1).finally(() => setLoading(false));
}, []);
const handleLoadMore = async () => {
const next = page + 1
setLoadingMore(true)
await fetchReleases(next, true)
setPage(next)
setLoadingMore(false)
}
const next = page + 1;
setLoadingMore(true);
await fetchReleases(next, true);
setPage(next);
setLoadingMore(false);
};
const toggleExpand = (id) => {
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
}
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
};
const formatDate = (dateStr) => {
const d = new Date(dateStr)
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
}
const d = new Date(dateStr);
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
};
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
const renderBody = (body) => {
if (!body) return null
const lines = body.split('\n')
const elements = []
let listItems = []
if (!body) return null;
const lines = body.split('\n');
const elements = [];
let listItems = [];
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
{listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
<span
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
style={{ background: 'var(--text-faint)' }}
/>
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
))}
</ul>
)
listItems = []
);
listItems = [];
}
}
};
const escapeHtml = (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const escapeHtml = (str) =>
str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const inlineFormat = (text) => {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
.replace(
/`(.+?)`/g,
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
})
}
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
});
};
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) { flushList(); continue }
const trimmed = line.trim();
if (!trimmed) {
flushList();
continue;
}
if (trimmed.startsWith('### ')) {
flushList()
flushList();
elements.push(
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
<h4
key={elements.length}
className="mb-1 mt-3 text-xs font-semibold"
style={{ color: 'var(--text-primary)' }}
>
{trimmed.slice(4)}
</h4>
)
);
} else if (trimmed.startsWith('## ')) {
flushList()
flushList();
elements.push(
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
<h3
key={elements.length}
className="mb-1 mt-3 text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
{trimmed.slice(3)}
</h3>
)
);
} else if (/^[-*] /.test(trimmed)) {
listItems.push(trimmed.slice(2))
listItems.push(trimmed.slice(2));
} else {
flushList()
flushList();
elements.push(
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
<p
key={elements.length}
className="my-1 text-xs"
style={{ color: 'var(--text-muted)' }}
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/>
)
);
}
}
flushList()
return elements
}
flushList();
return elements;
};
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff5e5b';
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#ff5e5b15',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Coffee size={20} style={{ color: '#ff5e5b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Ko-fi
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.support')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
@@ -148,56 +202,121 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ffdd00';
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#ffdd0015',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Heart size={20} style={{ color: '#ffdd00' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Buy Me a Coffee
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.support')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#5865F2';
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#5865F215',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Discord
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
Join the community
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ef4444';
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#ef444415',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Bug size={20} style={{ color: '#ef4444' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('settings.about.reportBug')}
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.about.reportBugHint')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
@@ -205,17 +324,38 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#f59e0b';
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#f59e0b15',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('settings.about.featureRequest')}
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.about.featureRequestHint')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
@@ -223,17 +363,38 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://github.com/mauriceboe/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
className="flex items-center gap-4 overflow-hidden rounded-xl border px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: '#6366f115',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<BookOpen size={20} style={{ color: '#6366f1' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Wiki
</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('settings.about.wikiHint')}
</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
@@ -241,142 +402,169 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Loading / Error / Releases */}
{loading ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
</div>
</div>
) : error ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="p-6 text-center">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
{t('admin.github.error')}
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--text-faint)' }}>
{error}
</p>
</div>
</div>
) : (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
<div
className="overflow-hidden rounded-xl border"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div
className="flex items-center justify-between border-b px-5 py-4"
style={{ borderColor: 'var(--border-secondary)' }}
>
<ExternalLink size={12} />
GitHub
</a>
</div>
<div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('admin.github.title')}
</h2>
<p className="mt-0.5 text-xs" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.subtitle').replace('{repo}', REPO)}
</p>
</div>
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
<ExternalLink size={12} />
GitHub
</a>
</div>
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
{/* Timeline */}
<div className="px-5 py-4">
<div className="relative">
{/* Timeline line */}
<div
className="absolute bottom-3 left-[11px] top-3 w-px"
style={{ background: 'var(--border-primary)' }}
/>
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0
const isExpanded = expanded[release.id]
<div className="space-y-0">
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0;
const isExpanded = expanded[release.id];
return (
<div key={release.id} className="relative pl-8 pb-5">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{release.tag_name}
</span>
{isLatest && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
{t('admin.github.prerelease')}
</span>
)}
return (
<div key={release.id} className="relative pb-5 pl-8">
{/* Timeline dot */}
<div
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
style={{
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
}}
>
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
</div>
{release.name && release.name !== release.tag_name && (
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
{release.name}
</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.by')} {release.author.login}
{/* Release content */}
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{release.tag_name}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
style={{ color: 'var(--text-muted)' }}
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
{renderBody(release.body)}
</div>
{isLatest && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}
>
{t('admin.github.latest')}
</span>
)}
{release.prerelease && (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}
>
{t('admin.github.prerelease')}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Load more */}
{hasMore && (
<div className="text-center pt-2">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
{release.name && release.name !== release.tag_name && (
<p className="mt-0.5 text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
{release.name}
</p>
)}
<div className="mt-1 flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
<Calendar size={10} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button
onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
style={{ color: 'var(--text-muted)' }}
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2 rounded-lg p-3" style={{ background: 'var(--bg-secondary)' }}>
{renderBody(release.body)}
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Load more */}
{hasMore && (
<div className="pt-2 text-center">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
>
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
);
}
@@ -1,18 +1,18 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import PackingTemplateManager from './PackingTemplateManager';
import { ToastContainer } from '../shared/Toast';
import PackingTemplateManager from './PackingTemplateManager';
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' };
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' };
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 };
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 };
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 };
beforeEach(() => {
resetAllStores();
@@ -22,7 +22,7 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
server.use(
http.get('/api/admin/packing-templates', async () => {
await new Promise(r => setTimeout(r, 100));
await new Promise((r) => setTimeout(r, 100));
return HttpResponse.json({ templates: [] });
})
);
@@ -37,11 +37,7 @@ describe('PackingTemplateManager', () => {
});
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1, tmpl2] })
)
);
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })));
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
@@ -67,7 +63,12 @@ describe('PackingTemplateManager', () => {
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
})
);
render(<><ToastContainer /><PackingTemplateManager /></>);
render(
<>
<ToastContainer />
<PackingTemplateManager />
</>
);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
@@ -101,12 +102,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
)
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
@@ -119,12 +116,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
)
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
@@ -142,22 +135,25 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1, tmpl2] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })),
http.delete('/api/admin/packing-templates/1', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<><ToastContainer /><PackingTemplateManager /></>);
render(
<>
<ToastContainer />
<PackingTemplateManager />
</>
);
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
// Find all Trash2 (delete) buttons — there are 2 (one per template)
const deleteButtons = screen.getAllByRole('button').filter(b =>
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
);
const deleteButtons = screen
.getAllByRole('button')
.filter((b) => b.className.includes('hover:bg-red-50') || b.querySelector('svg'));
// Click the delete button for "Beach Trip" (first template row's trash button)
// The buttons layout in each row: [chevron, edit, delete]
// We find rows first
@@ -168,7 +164,7 @@ describe('PackingTemplateManager', () => {
} else {
// Fallback: find all red-hover buttons and click first
const allBtns = screen.getAllByRole('button');
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
const redBtns = allBtns.filter((b) => b.className.includes('hover:bg-red-50'));
await user.click(redBtns[0]);
}
await waitFor(() => expect(deleteCalled).toBe(true));
@@ -181,9 +177,7 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
@@ -201,7 +195,7 @@ describe('PackingTemplateManager', () => {
} else {
// Fallback: find all slate-100-hover buttons
const allBtns = screen.getAllByRole('button');
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
@@ -215,12 +209,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [], items: [] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', async () =>
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
)
@@ -239,12 +229,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
)
@@ -269,15 +255,9 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [] })
),
http.put('/api/admin/packing-templates/1/categories/10', async () =>
HttpResponse.json({ success: true })
)
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.put('/api/admin/packing-templates/1/categories/10', async () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
@@ -286,8 +266,8 @@ describe('PackingTemplateManager', () => {
// Find the Edit2 button in the Clothing category header
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
b => b.className.includes('hover:text-slate-700')
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter((b) =>
b.className.includes('hover:text-slate-700')
);
// Second button (after Plus) is Edit2
await user.click(editBtns[1]);
@@ -301,15 +281,11 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
http.delete('/api/admin/packing-templates/1/categories/10', () =>
HttpResponse.json({ success: true })
)
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
@@ -331,15 +307,9 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1] })
),
http.put('/api/admin/packing-templates/1/items/100', async () =>
HttpResponse.json({ success: true })
)
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
http.put('/api/admin/packing-templates/1/items/100', async () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
@@ -348,9 +318,9 @@ describe('PackingTemplateManager', () => {
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
const itemRow = screen.getByText('T-shirt').closest('div')!;
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
b => b.className.includes('opacity-0')
) as HTMLElement | undefined;
const editBtn = Array.from(itemRow.querySelectorAll('button')).find((b) => b.className.includes('opacity-0')) as
| HTMLElement
| undefined;
if (editBtn) {
await user.click(editBtn);
} else {
@@ -368,15 +338,11 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
http.delete('/api/admin/packing-templates/1/items/100', () =>
HttpResponse.json({ success: true })
)
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.json({ success: true }))
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
@@ -386,9 +352,7 @@ describe('PackingTemplateManager', () => {
// Find the Trash2 button in the T-shirt row
const itemRow = screen.getByText('T-shirt').closest('div')!;
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
b => b.className.includes('opacity-0')
);
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter((b) => b.className.includes('opacity-0'));
// Second opacity-0 button is the delete (trash) button
const trashBtn = trashBtns[1] || trashBtns[0];
await user.click(trashBtn as HTMLElement);
@@ -401,12 +365,8 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [], items: [] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', async () => {
postCalled = true;
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
@@ -419,9 +379,7 @@ describe('PackingTemplateManager', () => {
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Test{Escape}');
await waitFor(() =>
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument()
);
await waitFor(() => expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument());
expect(postCalled).toBe(false);
});
@@ -429,12 +387,8 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
postCalled = true;
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
@@ -451,9 +405,7 @@ describe('PackingTemplateManager', () => {
const itemInput = screen.getByPlaceholderText('Item name');
await user.type(itemInput, 'Test{Escape}');
await waitFor(() =>
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
);
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
expect(postCalled).toBe(false);
});
@@ -461,9 +413,7 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
@@ -479,7 +429,7 @@ describe('PackingTemplateManager', () => {
await user.click(editBtn);
} else {
const allBtns = screen.getAllByRole('button');
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
@@ -1,260 +1,414 @@
import { useState, useEffect, useRef } from 'react'
import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
import { Check, ChevronDown, ChevronRight, Edit2, FolderPlus, Package, Plus, Trash2, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useToast } from '../shared/Toast';
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
interface TemplateCategory {
id: number;
template_id: number;
name: string;
sort_order: number;
}
interface TemplateItem {
id: number;
category_id: number;
name: string;
sort_order: number;
}
interface Template {
id: number;
name: string;
item_count: number;
category_count: number;
created_by_name: string;
}
export default function PackingTemplateManager() {
const [templates, setTemplates] = useState<Template[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [createName, setCreateName] = useState('')
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState('');
// Expanded template state
const [expandedId, setExpandedId] = useState<number | null>(null)
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [items, setItems] = useState<TemplateItem[]>([])
const [expandedId, setExpandedId] = useState<number | null>(null);
const [categories, setCategories] = useState<TemplateCategory[]>([]);
const [items, setItems] = useState<TemplateItem[]>([]);
// Editing states
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
const [editTemplateName, setEditTemplateName] = useState('')
const [editingCatId, setEditingCatId] = useState<number | null>(null)
const [editCatName, setEditCatName] = useState('')
const [editingItemId, setEditingItemId] = useState<number | null>(null)
const [editItemName, setEditItemName] = useState('')
const [editingTemplate, setEditingTemplate] = useState<number | null>(null);
const [editTemplateName, setEditTemplateName] = useState('');
const [editingCatId, setEditingCatId] = useState<number | null>(null);
const [editCatName, setEditCatName] = useState('');
const [editingItemId, setEditingItemId] = useState<number | null>(null);
const [editItemName, setEditItemName] = useState('');
// Adding states
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null)
const [addingCategory, setAddingCategory] = useState(false);
const [newCatName, setNewCatName] = useState('');
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null);
const [newItemName, setNewItemName] = useState('');
const addItemRef = useRef<HTMLInputElement>(null);
const toast = useToast()
const { t } = useTranslation()
const toast = useToast();
const { t } = useTranslation();
useEffect(() => { loadTemplates() }, [])
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
setIsLoading(true)
setIsLoading(true);
try {
const data = await adminApi.packingTemplates()
setTemplates(data.templates || [])
} catch { toast.error(t('admin.packingTemplates.loadError')) }
finally { setIsLoading(false) }
}
const data = await adminApi.packingTemplates();
setTemplates(data.templates || []);
} catch {
toast.error(t('admin.packingTemplates.loadError'));
} finally {
setIsLoading(false);
}
};
const toggleExpand = async (id: number) => {
if (expandedId === id) { setExpandedId(null); return }
setExpandedId(id)
setAddingCategory(false)
setAddingItemToCatId(null)
if (expandedId === id) {
setExpandedId(null);
return;
}
setExpandedId(id);
setAddingCategory(false);
setAddingItemToCatId(null);
try {
const data = await adminApi.getPackingTemplate(id)
setCategories(data.categories || [])
setItems(data.items || [])
} catch { toast.error(t('admin.packingTemplates.loadError')) }
}
const data = await adminApi.getPackingTemplate(id);
setCategories(data.categories || []);
setItems(data.items || []);
} catch {
toast.error(t('admin.packingTemplates.loadError'));
}
};
// Template CRUD
const handleCreateTemplate = async () => {
if (!createName.trim()) return
if (!createName.trim()) return;
try {
const data = await adminApi.createPackingTemplate({ name: createName.trim() })
setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
setCreateName(''); setShowCreate(false)
setExpandedId(data.template.id); setCategories([]); setItems([])
toast.success(t('admin.packingTemplates.created'))
} catch { toast.error(t('admin.packingTemplates.createError')) }
}
const data = await adminApi.createPackingTemplate({ name: createName.trim() });
setTemplates((prev) => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev]);
setCreateName('');
setShowCreate(false);
setExpandedId(data.template.id);
setCategories([]);
setItems([]);
toast.success(t('admin.packingTemplates.created'));
} catch {
toast.error(t('admin.packingTemplates.createError'));
}
};
const handleDeleteTemplate = async (id: number) => {
try {
await adminApi.deletePackingTemplate(id)
setTemplates(prev => prev.filter(t => t.id !== id))
if (expandedId === id) setExpandedId(null)
toast.success(t('admin.packingTemplates.deleted'))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
await adminApi.deletePackingTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
if (expandedId === id) setExpandedId(null);
toast.success(t('admin.packingTemplates.deleted'));
} catch {
toast.error(t('admin.packingTemplates.deleteError'));
}
};
const handleRenameTemplate = async (id: number) => {
if (!editTemplateName.trim()) { setEditingTemplate(null); return }
if (!editTemplateName.trim()) {
setEditingTemplate(null);
return;
}
try {
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
setEditingTemplate(null)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() });
setTemplates((prev) => prev.map((t) => (t.id === id ? { ...t, name: editTemplateName.trim() } : t)));
setEditingTemplate(null);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
// Category CRUD
const handleAddCategory = async () => {
if (!newCatName.trim() || !expandedId) return
if (!newCatName.trim() || !expandedId) return;
try {
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
setCategories(prev => [...prev, data.category])
setNewCatName(''); setAddingCategory(false)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() });
setCategories((prev) => [...prev, data.category]);
setNewCatName('');
setAddingCategory(false);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleRenameCategory = async (catId: number) => {
if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
if (!editCatName.trim() || !expandedId) {
setEditingCatId(null);
return;
}
try {
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
setEditingCatId(null)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() });
setCategories((prev) => prev.map((c) => (c.id === catId ? { ...c, name: editCatName.trim() } : c)));
setEditingCatId(null);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleDeleteCategory = async (catId: number) => {
if (!expandedId) return
if (!expandedId) return;
try {
await adminApi.deleteTemplateCategory(expandedId, catId)
setCategories(prev => prev.filter(c => c.id !== catId))
setItems(prev => prev.filter(i => i.category_id !== catId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
await adminApi.deleteTemplateCategory(expandedId, catId);
setCategories((prev) => prev.filter((c) => c.id !== catId));
setItems((prev) => prev.filter((i) => i.category_id !== catId));
} catch {
toast.error(t('admin.packingTemplates.deleteError'));
}
};
// Item CRUD
const handleAddItem = async (catId: number) => {
if (!newItemName.trim() || !expandedId) return
if (!newItemName.trim() || !expandedId) return;
try {
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
setItems(prev => [...prev, data.item])
setNewItemName('')
setTimeout(() => addItemRef.current?.focus(), 30)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() });
setItems((prev) => [...prev, data.item]);
setNewItemName('');
setTimeout(() => addItemRef.current?.focus(), 30);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleRenameItem = async (itemId: number) => {
if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
if (!editItemName.trim() || !expandedId) {
setEditingItemId(null);
return;
}
try {
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
setEditingItemId(null)
} catch { toast.error(t('admin.packingTemplates.saveError')) }
}
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() });
setItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, name: editItemName.trim() } : i)));
setEditingItemId(null);
} catch {
toast.error(t('admin.packingTemplates.saveError'));
}
};
const handleDeleteItem = async (itemId: number) => {
if (!expandedId) return
if (!expandedId) return;
try {
await adminApi.deleteTemplateItem(expandedId, itemId)
setItems(prev => prev.filter(i => i.id !== itemId))
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
}
await adminApi.deleteTemplateItem(expandedId, itemId);
setItems((prev) => prev.filter((i) => i.id !== itemId));
} catch {
toast.error(t('admin.packingTemplates.deleteError'));
}
};
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
const btnIcon = 'p-1.5 rounded-lg transition-colors'
const inputStyle =
'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none';
const btnIcon = 'p-1.5 rounded-lg transition-colors';
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
{/* Header */}
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div className="flex items-center justify-between border-b border-slate-100 p-5">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
<p className="mt-1 text-xs text-slate-400">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm text-white transition-colors hover:bg-slate-700"
>
<Plus className="h-4 w-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
</button>
</div>
{/* Create template */}
{showCreate && (
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
<Package size={16} className="text-slate-400 flex-shrink-0" />
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<Package size={16} className="flex-shrink-0 text-slate-400" />
<input
autoFocus
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateTemplate();
if (e.key === 'Escape') setShowCreate(false);
}}
placeholder={t('admin.packingTemplates.namePlaceholder')}
className={inputStyle}
/>
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}>
<Check size={16} />
</button>
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}>
<X size={16} />
</button>
</div>
)}
{/* Template list */}
{isLoading ? (
<div className="p-8 text-center"><div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /></div>
<div className="p-8 text-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900" />
</div>
) : templates.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
) : (
<div className="divide-y divide-slate-100">
{templates.map(tmpl => (
{templates.map((tmpl) => (
<div key={tmpl.id}>
{/* Template row */}
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
<div className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-slate-50">
<button
onClick={() => toggleExpand(tmpl.id)}
className="flex-shrink-0 cursor-pointer border-none bg-transparent p-0 text-slate-400"
>
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<Package size={16} className="text-slate-400 flex-shrink-0" />
<Package size={16} className="flex-shrink-0 text-slate-400" />
{editingTemplate === tmpl.id ? (
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
<input
autoFocus
value={editTemplateName}
onChange={(e) => setEditTemplateName(e.target.value)}
onBlur={() => handleRenameTemplate(tmpl.id)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameTemplate(tmpl.id);
if (e.key === 'Escape') setEditingTemplate(null);
}}
className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm"
/>
) : (
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
<span
onClick={() => toggleExpand(tmpl.id)}
className="flex-1 cursor-pointer text-sm font-medium text-slate-700"
>
{tmpl.name}
</span>
)}
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-400">
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count}{' '}
{t('admin.packingTemplates.items')}
</span>
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
<button onClick={() => handleDeleteTemplate(tmpl.id)}
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
<button
onClick={() => {
setEditingTemplate(tmpl.id);
setEditTemplateName(tmpl.name);
}}
className={`${btnIcon} text-slate-400 hover:bg-slate-100 hover:text-slate-700`}
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDeleteTemplate(tmpl.id)}
className={`${btnIcon} text-slate-400 hover:bg-red-50 hover:text-red-500`}
>
<Trash2 size={14} />
</button>
</div>
{/* Expanded content */}
{expandedId === tmpl.id && (
<div className="px-5 pb-4 ml-8 space-y-3">
{categories.map(cat => {
const catItems = items.filter(i => i.category_id === cat.id)
<div className="ml-8 space-y-3 px-5 pb-4">
{categories.map((cat) => {
const catItems = items.filter((i) => i.category_id === cat.id);
return (
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
<div key={cat.id} className="overflow-hidden rounded-lg border border-slate-200">
{/* Category header */}
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
<div className="flex items-center gap-2 bg-slate-50 px-4 py-2.5">
{editingCatId === cat.id ? (
<>
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
<input
autoFocus
value={editCatName}
onChange={(e) => setEditCatName(e.target.value)}
onBlur={() => handleRenameCategory(cat.id)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameCategory(cat.id);
if (e.key === 'Escape') setEditingCatId(null);
}}
className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm font-semibold"
/>
</>
) : (
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
<span className="flex-1 text-xs font-bold uppercase tracking-wider text-slate-500">
{cat.name}
</span>
)}
<span className="text-xs text-slate-400">{catItems.length}</span>
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
<button onClick={() => handleDeleteCategory(cat.id)}
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
<button
onClick={() => {
setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id);
setNewItemName('');
setTimeout(() => addItemRef.current?.focus(), 30);
}}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}
>
<Plus size={13} />
</button>
<button
onClick={() => {
setEditingCatId(cat.id);
setEditCatName(cat.name);
}}
className={`${btnIcon} text-slate-400 hover:text-slate-700`}
>
<Edit2 size={13} />
</button>
<button
onClick={() => handleDeleteCategory(cat.id)}
className={`${btnIcon} text-slate-400 hover:text-red-500`}
>
<Trash2 size={13} />
</button>
</div>
{/* Items */}
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
<div className="divide-y divide-slate-50">
{catItems.map(item => (
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
{catItems.map((item) => (
<div key={item.id} className="group flex items-center gap-3 px-4 py-2">
{editingItemId === item.id ? (
<>
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
<input
autoFocus
value={editItemName}
onChange={(e) => setEditItemName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameItem(item.id);
if (e.key === 'Escape') setEditingItemId(null);
}}
className="flex-1 rounded-lg border border-slate-200 px-2 py-1 text-sm"
/>
<button
onClick={() => handleRenameItem(item.id)}
className="p-1 text-slate-600 hover:text-slate-900"
>
<Check size={13} />
</button>
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400">
<X size={13} />
</button>
</>
) : (
<>
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
<button onClick={() => handleDeleteItem(item.id)}
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
<button
onClick={() => {
setEditingItemId(item.id);
setEditItemName(item.name);
}}
className="rounded p-1 text-slate-400 opacity-0 transition-all hover:text-slate-700 group-hover:opacity-100"
>
<Edit2 size={12} />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="rounded p-1 text-slate-400 opacity-0 transition-all hover:text-red-500 group-hover:opacity-100"
>
<Trash2 size={12} />
</button>
</>
)}
</div>
@@ -263,35 +417,79 @@ export default function PackingTemplateManager() {
{/* Add item inline */}
{addingItemToCatId === cat.id && (
<div className="flex items-center gap-2 px-4 py-2">
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
<input
ref={addItemRef}
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id);
if (e.key === 'Escape') {
setAddingItemToCatId(null);
setNewItemName('');
}
}}
placeholder={t('admin.packingTemplates.itemName')}
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
className="flex-1 rounded-lg border border-slate-200 px-2 py-1 text-sm"
/>
<button
onClick={() => handleAddItem(cat.id)}
disabled={!newItemName.trim()}
className="rounded-lg bg-slate-900 p-1.5 text-white transition-colors hover:bg-slate-700 disabled:bg-slate-300"
>
<Plus size={13} />
</button>
<button
onClick={() => {
setAddingItemToCatId(null);
setNewItemName('');
}}
className="p-1 text-slate-400 hover:text-slate-600"
>
<X size={13} />
</button>
</div>
)}
</div>
)}
</div>
)
);
})}
{/* Add category button */}
{addingCategory ? (
<div className="flex items-center gap-2">
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
<input
autoFocus
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddCategory();
if (e.key === 'Escape') {
setAddingCategory(false);
setNewCatName('');
}
}}
placeholder={t('admin.packingTemplates.categoryName')}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
className="flex-1 rounded-lg border border-slate-200 px-3 py-2 text-sm"
/>
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}>
<Check size={15} />
</button>
<button
onClick={() => {
setAddingCategory(false);
setNewCatName('');
}}
className={`${btnIcon} text-slate-400`}
>
<X size={15} />
</button>
</div>
) : (
<button onClick={() => setAddingCategory(true)}
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
<button
onClick={() => setAddingCategory(true)}
className="flex w-full items-center gap-2 rounded-lg border border-dashed border-slate-200 px-3 py-2.5 text-sm text-slate-400 transition-colors hover:border-slate-400 hover:text-slate-600"
>
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
</button>
)}
@@ -302,5 +500,5 @@ export default function PackingTemplateManager() {
</div>
)}
</div>
)
);
}
@@ -1,8 +1,8 @@
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import PermissionsPanel from './PermissionsPanel';
@@ -41,7 +41,7 @@ function renderPanel() {
<>
<ToastContainer />
<PermissionsPanel />
</>,
</>
);
}
@@ -50,11 +50,7 @@ function renderPanel() {
beforeEach(() => {
resetAllStores();
// Override the default handler (returns object) with correct array shape
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
),
);
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
});
afterEach(() => {
@@ -69,7 +65,7 @@ describe('PermissionsPanel', () => {
http.get('/api/admin/permissions', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ permissions: [] });
}),
})
);
renderPanel();
const spinner = document.querySelector('.animate-spin');
@@ -95,11 +91,7 @@ describe('PermissionsPanel', () => {
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
];
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ permissions: perms }),
),
);
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
renderPanel();
await screen.findByText('Trip Management');
// Badge should appear once (for trip_create)
@@ -150,13 +142,9 @@ describe('PermissionsPanel', () => {
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
const perms = [
buildPermission('trip_create', 'admin', 'trip_member'), // customized
...SAMPLE_PERMISSIONS.filter(p => p.key !== 'trip_create'),
...SAMPLE_PERMISSIONS.filter((p) => p.key !== 'trip_create'),
];
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ permissions: perms }),
),
);
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
@@ -179,11 +167,7 @@ describe('PermissionsPanel', () => {
});
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
server.use(
http.put('/api/admin/permissions', () =>
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
),
);
server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
@@ -204,11 +188,7 @@ describe('PermissionsPanel', () => {
});
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
server.use(
http.put('/api/admin/permissions', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 }),
),
);
server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
@@ -231,12 +211,13 @@ describe('PermissionsPanel', () => {
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
let resolvePut!: () => void;
server.use(
http.put('/api/admin/permissions', () =>
new Promise<Response>(resolve => {
resolvePut = () =>
resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
}),
),
http.put(
'/api/admin/permissions',
() =>
new Promise<Response>((resolve) => {
resolvePut = () => resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
})
)
);
const user = userEvent.setup();
renderPanel();
@@ -263,11 +244,7 @@ describe('PermissionsPanel', () => {
});
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 }),
),
);
server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
renderPanel();
await screen.findByText('Error');
});
@@ -1,16 +1,16 @@
import React, { useEffect, useState, useMemo } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { Save, Loader2, RotateCcw } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { Loader2, RotateCcw, Save } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { PermissionLevel, usePermissionsStore } from '../../store/permissionsStore';
import CustomSelect from '../shared/CustomSelect';
import { useToast } from '../shared/Toast';
interface PermissionEntry {
key: string
level: PermissionLevel
defaultLevel: PermissionLevel
allowedLevels: PermissionLevel[]
key: string;
level: PermissionLevel;
defaultLevel: PermissionLevel;
allowedLevels: PermissionLevel[];
}
const LEVEL_LABELS: Record<string, string> = {
@@ -18,7 +18,7 @@ const LEVEL_LABELS: Record<string, string> = {
trip_owner: 'perm.level.tripOwner',
trip_member: 'perm.level.tripMember',
everybody: 'perm.level.everybody',
}
};
const CATEGORIES = [
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
@@ -26,131 +26,133 @@ const CATEGORIES = [
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
]
];
export default function PermissionsPanel(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [entries, setEntries] = useState<PermissionEntry[]>([])
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const { t } = useTranslation();
const toast = useToast();
const [entries, setEntries] = useState<PermissionEntry[]>([]);
const [values, setValues] = useState<Record<string, PermissionLevel>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
useEffect(() => {
loadPermissions()
}, [])
loadPermissions();
}, []);
const loadPermissions = async () => {
setLoading(true)
setLoading(true);
try {
const data = await adminApi.getPermissions()
setEntries(data.permissions)
const vals: Record<string, PermissionLevel> = {}
for (const p of data.permissions) vals[p.key] = p.level
setValues(vals)
setDirty(false)
const data = await adminApi.getPermissions();
setEntries(data.permissions);
const vals: Record<string, PermissionLevel> = {};
for (const p of data.permissions) vals[p.key] = p.level;
setValues(vals);
setDirty(false);
} catch {
toast.error(t('common.error'))
toast.error(t('common.error'));
} finally {
setLoading(false)
setLoading(false);
}
}
};
const handleChange = (key: string, level: PermissionLevel) => {
setValues(prev => ({ ...prev, [key]: level }))
setDirty(true)
}
setValues((prev) => ({ ...prev, [key]: level }));
setDirty(true);
};
const handleSave = async () => {
setSaving(true)
setSaving(true);
try {
const data = await adminApi.updatePermissions(values)
const data = await adminApi.updatePermissions(values);
if (data.permissions) {
usePermissionsStore.getState().setPermissions(data.permissions)
usePermissionsStore.getState().setPermissions(data.permissions);
}
setDirty(false)
toast.success(t('perm.saved'))
setDirty(false);
toast.success(t('perm.saved'));
} catch {
toast.error(t('common.error'))
toast.error(t('common.error'));
} finally {
setSaving(false)
setSaving(false);
}
}
};
const handleReset = () => {
const defaults: Record<string, PermissionLevel> = {}
for (const p of entries) defaults[p.key] = p.defaultLevel
setValues(defaults)
setDirty(true)
}
const defaults: Record<string, PermissionLevel> = {};
for (const p of entries) defaults[p.key] = p.defaultLevel;
setValues(defaults);
setDirty(true);
};
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
const entryMap = useMemo(() => new Map(entries.map((e) => [e.key, e])), [entries]);
if (loading) {
return (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" />
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-slate-900" />
</div>
)
);
}
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="font-semibold text-slate-900">{t('perm.title')}</h2>
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
<p className="mt-0.5 text-xs text-slate-400">{t('perm.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleReset}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
title={t('perm.resetDefaults')}
aria-label={t('perm.resetDefaults')}
className="flex w-8 items-center justify-center gap-1.5 rounded-lg border border-slate-300 px-0 py-1.5 text-sm transition-colors hover:bg-slate-50 disabled:opacity-40 sm:w-auto sm:px-3"
>
<RotateCcw className="w-3.5 h-3.5" />
{t('perm.resetDefaults')}
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
</button>
<button
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
className="flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-sm text-white transition-colors hover:bg-slate-700 disabled:bg-slate-400"
>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{t('common.save')}
</button>
</div>
</div>
<div className="divide-y divide-slate-100">
{CATEGORIES.map(cat => (
{CATEGORIES.map((cat) => (
<div key={cat.id} className="px-6 py-4">
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
{t(`perm.cat.${cat.id}`)}
</h3>
<div className="space-y-3">
{cat.keys.map(key => {
const entry = entryMap.get(key)
if (!entry) return null
const currentLevel = values[key] || entry.defaultLevel
const isDefault = currentLevel === entry.defaultLevel
{cat.keys.map((key) => {
const entry = entryMap.get(key);
if (!entry) return null;
const currentLevel = values[key] || entry.defaultLevel;
const isDefault = currentLevel === entry.defaultLevel;
return (
<div key={key} className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p>
<p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p>
<p className="mt-0.5 text-xs text-slate-400">{t(`perm.actionHint.${key}`)}</p>
</div>
<div className="flex items-center gap-2">
{!isDefault && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
{t('perm.customized')}
</span>
)}
<CustomSelect
value={currentLevel}
onChange={(val) => handleChange(key, val as PermissionLevel)}
options={entry.allowedLevels.map(l => ({
options={entry.allowedLevels.map((l) => ({
value: l,
label: t(LEVEL_LABELS[l] || l),
}))}
@@ -158,7 +160,7 @@ export default function PermissionsPanel(): React.ReactElement {
/>
</div>
</div>
)
);
})}
</div>
</div>
@@ -166,5 +168,5 @@ export default function PermissionsPanel(): React.ReactElement {
</div>
</div>
</div>
)
);
}
+68 -102
View File
@@ -1,26 +1,22 @@
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildBudgetItem, buildSettings, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import { useAuthStore } from '../../store/authStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import BudgetPanel from './BudgetPanel';
beforeEach(() => {
resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel
server.use(
http.get('/api/trips/:id/budget/settlement', () =>
HttpResponse.json({ balances: [], flows: [] })
),
http.get('/api/trips/:id/budget/per-person', () =>
HttpResponse.json({ summary: [] })
),
http.get('/api/trips/:id/budget/settlement', () => HttpResponse.json({ balances: [], flows: [] })),
http.get('/api/trips/:id/budget/per-person', () => HttpResponse.json({ summary: [] }))
);
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
@@ -28,52 +24,40 @@ beforeEach(() => {
describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText(/Create categories and entries/i);
});
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
});
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel Paris');
});
it('FE-COMP-BUDGET-005: renders category section header', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
});
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Name');
await screen.findByText('Total');
@@ -81,27 +65,21 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Budget');
});
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
});
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
});
@@ -112,7 +90,7 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const body = (await request.json()) as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
return HttpResponse.json({ item });
})
@@ -127,9 +105,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Test Item');
// Delete button has title="Delete"
@@ -154,9 +130,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel A');
await screen.findByText('Hotel B');
@@ -165,9 +139,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
await screen.findByText('Hotels');
@@ -175,9 +147,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
// Component renders even in empty state
await screen.findByText('No budget created yet');
@@ -186,9 +156,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Misc');
// Row exists - EUR formatting would appear in values
@@ -196,9 +164,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('ToDelete');
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
@@ -206,9 +172,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
// The add button is present
@@ -221,7 +185,7 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const body = (await request.json()) as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
return HttpResponse.json({ item });
})
@@ -233,9 +197,7 @@ describe('BudgetPanel', () => {
});
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} tripMembers={[]} />);
await screen.findByText('No budget created yet');
});
@@ -243,9 +205,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Old Name');
await user.click(screen.getByText('Old Name'));
@@ -261,7 +221,7 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.put('/api/trips/1/budget/10', async ({ request }) => {
const b = await request.json() as Record<string, unknown>;
const b = (await request.json()) as Record<string, unknown>;
putCalled = true;
return HttpResponse.json({ item: { ...item, name: b.name } });
})
@@ -277,10 +237,11 @@ describe('BudgetPanel', () => {
});
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
const item = {
...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }),
total_price: 45.5,
};
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Dinner');
// The formatted number appears in the InlineEditCell for total price (and grand total card)
@@ -291,7 +252,10 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 };
const item = {
...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }),
total_price: 200,
};
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
@@ -311,9 +275,7 @@ describe('BudgetPanel', () => {
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
await user.click(screen.getByText('CSV'));
@@ -324,9 +286,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Lunch');
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
@@ -334,9 +294,7 @@ describe('BudgetPanel', () => {
});
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
});
@@ -346,7 +304,9 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } })
HttpResponse.json({
item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 },
})
)
);
render(<BudgetPanel tripId={1} />);
@@ -410,14 +370,12 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Flight');
await screen.findByText('Hotel');
// Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument();
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
});
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
@@ -427,9 +385,7 @@ describe('BudgetPanel', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Read Only Item');
// In read-only mode the Delete button should not be visible
@@ -440,10 +396,12 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }),
total_price: 30,
expense_date: '2025-06-15',
};
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
@@ -461,10 +419,16 @@ describe('BudgetPanel', () => {
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
flows: [
{
from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
to: { username: 'bob', avatar_url: null },
amount: 30,
},
],
})
),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] }))
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
@@ -485,10 +449,12 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }),
total_price: 5,
expense_date: null,
};
server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(<BudgetPanel tripId={1} />);
await screen.findByText('Snack');
// When expense_date is null, the fallback '—' is shown
File diff suppressed because it is too large Load Diff
+362 -145
View File
@@ -15,17 +15,17 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, waitFor, act, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabChat from './CollabChat';
import { addListener } from '../../api/websocket';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import CollabChat from './CollabChat';
const currentUser = buildUser({ id: 1, username: 'testuser' });
@@ -36,11 +36,7 @@ const defaultProps = {
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({ messages: [], total: 0 })
),
);
server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json({ messages: [], total: 0 })));
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
@@ -75,11 +71,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser',
avatar_url: null, text: 'Hello world!', created_at: '2025-06-01T10:00:00.000Z',
reactions: {}, reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: currentUser.id,
username: 'testuser',
avatar_url: null,
text: 'Hello world!',
created_at: '2025-06-01T10:00:00.000Z',
reactions: {},
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -104,9 +110,17 @@ describe('CollabChat', () => {
http.post('/api/trips/1/collab/messages', async () => {
postCalled = true;
return HttpResponse.json({
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
avatar_url: null, text: 'New message', created_at: new Date().toISOString(),
reactions: {}, reply_to: null, deleted: false, edited: false,
id: 2,
trip_id: 1,
user_id: 1,
username: 'testuser',
avatar_url: null,
text: 'New message',
created_at: new Date().toISOString(),
reactions: {},
reply_to: null,
deleted: false,
edited: false,
});
})
);
@@ -139,8 +153,32 @@ describe('CollabChat', () => {
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{ id: 1, trip_id: 1, user_id: 1, username: 'testuser', avatar_url: null, text: 'First message', created_at: '2025-06-01T10:00:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
{ id: 2, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null, text: 'Second message', created_at: '2025-06-01T10:01:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
{
id: 1,
trip_id: 1,
user_id: 1,
username: 'testuser',
avatar_url: null,
text: 'First message',
created_at: '2025-06-01T10:00:00.000Z',
reactions: {},
reply_to: null,
deleted: false,
edited: false,
},
{
id: 2,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Second message',
created_at: '2025-06-01T10:01:00.000Z',
reactions: {},
reply_to: null,
deleted: false,
edited: false,
},
],
total: 2,
})
@@ -163,11 +201,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Hello world!', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Hello world!',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -201,11 +249,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'some text', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: true, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'some text',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: true,
edited: false,
},
],
total: 1,
})
)
@@ -220,12 +278,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'React to me', created_at: new Date().toISOString(),
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'React to me',
created_at: new Date().toISOString(),
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -248,9 +315,16 @@ describe('CollabChat', () => {
type: 'collab:message:created',
tripId: 1,
message: {
id: 99, trip_id: 1, user_id: 2, username: 'alice',
text: 'WS message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
id: 99,
trip_id: 1,
user_id: 2,
username: 'alice',
text: 'WS message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
});
});
@@ -262,11 +336,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'To remove', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'To remove',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -289,7 +373,7 @@ describe('CollabChat', () => {
await screen.findByText('Start the conversation');
const buttons = screen.getAllByRole('button');
// The send button is the ArrowUp button — it has disabled attr when text is empty
const sendButton = buttons.find(b => b.hasAttribute('disabled'));
const sendButton = buttons.find((b) => b.hasAttribute('disabled'));
expect(sendButton).toBeTruthy();
expect(sendButton).toBeDisabled();
});
@@ -298,13 +382,23 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reply here', created_at: new Date().toISOString(),
reactions: [], reply_to: null,
reply_text: 'Original message', reply_username: 'alice',
deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Reply here',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
reply_text: 'Original message',
reply_username: 'alice',
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -318,11 +412,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
text: 'My own message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: currentUser.id,
username: 'testuser',
avatar_url: null,
text: 'My own message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -355,9 +459,17 @@ describe('CollabChat', () => {
http.post('/api/trips/1/collab/messages', async () =>
HttpResponse.json({
message: {
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
avatar_url: null, text: 'Sent message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
id: 2,
trip_id: 1,
user_id: 1,
username: 'testuser',
avatar_url: null,
text: 'Sent message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
})
)
@@ -375,15 +487,19 @@ describe('CollabChat', () => {
it('FE-COMP-CHAT-024: load earlier messages button appears when 100+ messages exist', async () => {
const messages = Array.from({ length: 100 }, (_, i) => ({
id: i + 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `Message ${i + 1}`, created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
id: i + 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: `Message ${i + 1}`,
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
}));
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({ messages, total: 100 })
)
);
server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json({ messages, total: 100 })));
render(<CollabChat {...defaultProps} />);
await screen.findByText('Message 1');
const loadMoreBtn = await screen.findByRole('button', { name: /load/i });
@@ -394,11 +510,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reply to me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Reply to me',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -412,7 +538,7 @@ describe('CollabChat', () => {
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
expect(aliceEls.some((el) => el.tagName === 'STRONG')).toBe(true);
});
});
@@ -420,11 +546,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Cancel reply test', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Cancel reply test',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -436,10 +572,10 @@ describe('CollabChat', () => {
// Wait for reply preview <strong> to appear
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
expect(aliceEls.some((el) => el.tagName === 'STRONG')).toBe(true);
});
// Find the X button inside the reply preview — the <strong> is inside a <span> inside the preview div
const strongEl = screen.getAllByText('alice').find(el => el.tagName === 'STRONG')!;
const strongEl = screen.getAllByText('alice').find((el) => el.tagName === 'STRONG')!;
const previewDiv = strongEl.closest('div[style]');
const xBtn = previewDiv?.querySelector('button');
expect(xBtn).toBeTruthy();
@@ -447,7 +583,7 @@ describe('CollabChat', () => {
await waitFor(() => {
// After cancel, no <strong>alice</strong> in reply preview
const remaining = screen.queryAllByText('alice');
expect(remaining.every(el => el.tagName !== 'STRONG')).toBe(true);
expect(remaining.every((el) => el.tagName !== 'STRONG')).toBe(true);
});
});
@@ -457,7 +593,7 @@ describe('CollabChat', () => {
await screen.findByText('Start the conversation');
// Smile button is the only non-disabled button when input is empty
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
const smileBtn = allButtons.find((b) => !b.hasAttribute('disabled'));
expect(smileBtn).toBeTruthy();
await user.click(smileBtn!);
// EmojiPicker renders category tabs
@@ -470,12 +606,12 @@ describe('CollabChat', () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
const smileBtn = allButtons.find((b) => !b.hasAttribute('disabled'));
await user.click(smileBtn!);
// Wait for picker to open
await screen.findByText('Smileys');
// Click the first emoji in the grid (😀 is the first in Smileys)
const emojiImg = screen.getAllByRole('img').find(img => img.getAttribute('alt') === '😀');
const emojiImg = screen.getAllByRole('img').find((img) => img.getAttribute('alt') === '😀');
expect(emojiImg).toBeTruthy();
await user.click(emojiImg!.closest('button')!);
// Emoji should be appended to textarea
@@ -487,11 +623,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Right click me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Right click me',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -502,9 +648,9 @@ describe('CollabChat', () => {
fireEvent.contextMenu(messageBubble!);
// ReactionMenu renders quick reactions (❤️ is the first)
await waitFor(() => {
const reactionImgs = screen.getAllByRole('img').filter(img =>
['❤️', '😂', '👍'].includes(img.getAttribute('alt') || '')
);
const reactionImgs = screen
.getAllByRole('img')
.filter((img) => ['❤️', '😂', '👍'].includes(img.getAttribute('alt') || ''));
expect(reactionImgs.length).toBeGreaterThan(0);
});
});
@@ -514,17 +660,29 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'React to this', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'React to this',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
),
http.post('/api/trips/1/collab/messages/1/react', async () => {
reactCalled = true;
return HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }] });
return HttpResponse.json({
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }],
});
})
);
render(<CollabChat {...defaultProps} />);
@@ -543,11 +701,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reacted message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Reacted message',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -569,9 +737,17 @@ describe('CollabChat', () => {
it('FE-COMP-CHAT-032: clicking "Load older messages" loads paginated results', async () => {
const initialMessages = Array.from({ length: 100 }, (_, i) => ({
id: i + 100, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `New ${i + 100}`, created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
id: i + 100,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: `New ${i + 100}`,
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
}));
let callCount = 0;
server.use(
@@ -581,11 +757,21 @@ describe('CollabChat', () => {
return HttpResponse.json({ messages: initialMessages, total: 120 });
}
return HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Older message', created_at: '2020-01-01T10:00:00.000Z',
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Older message',
created_at: '2020-01-01T10:00:00.000Z',
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 120,
});
})
@@ -602,17 +788,25 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
text: 'Delete me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: currentUser.id,
username: 'testuser',
avatar_url: null,
text: 'Delete me',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
),
http.delete('/api/trips/1/collab/messages/1', () =>
HttpResponse.json({ success: true })
)
http.delete('/api/trips/1/collab/messages/1', () => HttpResponse.json({ success: true }))
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Delete me');
@@ -620,21 +814,28 @@ describe('CollabChat', () => {
const deleteBtn = screen.getByTitle('Delete');
fireEvent.click(deleteBtn);
// handleDelete uses a 400ms setTimeout before calling the API
await waitFor(
() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(),
{ timeout: 1500 }
);
await waitFor(() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(), { timeout: 1500 });
});
it('FE-COMP-CHAT-034: single-emoji message renders as big emoji', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: '👍', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: '👍',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -661,11 +862,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Time format test', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: 'Time format test',
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
)
@@ -684,12 +895,21 @@ describe('CollabChat', () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `Check this out ${uniqueUrl}`,
created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
messages: [
{
id: 1,
trip_id: 1,
user_id: 2,
username: 'alice',
avatar_url: null,
text: `Check this out ${uniqueUrl}`,
created_at: new Date().toISOString(),
reactions: [],
reply_to: null,
deleted: false,
edited: false,
},
],
total: 1,
})
),
@@ -699,9 +919,6 @@ describe('CollabChat', () => {
);
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Check this out/);
await waitFor(
() => expect(screen.getByText('Preview Title')).toBeInTheDocument(),
{ timeout: 3000 }
);
await waitFor(() => expect(screen.getByText('Preview Title')).toBeInTheDocument(), { timeout: 3000 });
});
});
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
@@ -1,13 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser } from '../../../tests/helpers/factories'
import { useAuthStore } from '../../store/authStore'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }));
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }));
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }));
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }));
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
@@ -16,130 +16,130 @@ vi.mock('../../api/websocket', () => ({
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}))
}));
import CollabPanel from './CollabPanel'
import CollabPanel from './CollabPanel';
let originalInnerWidth: number
let originalInnerWidth: number;
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true })
window.dispatchEvent(new Event('resize'))
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true });
window.dispatchEvent(new Event('resize'));
}
describe('CollabPanel', () => {
beforeEach(() => {
originalInnerWidth = window.innerWidth
resetAllStores()
seedStore(useAuthStore, { user: buildUser() })
})
originalInnerWidth = window.innerWidth;
resetAllStores();
seedStore(useAuthStore, { user: buildUser() });
});
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
})
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true });
});
// FE-COMP-COLLABPANEL-001
it('desktop layout renders all four panels', () => {
setViewport(1280)
render(<CollabPanel tripId={1} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
})
setViewport(1280);
render(<CollabPanel tripId={1} />);
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-002
it('mobile layout renders tab bar, not all panels at once', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
setViewport(375);
render(<CollabPanel tripId={1} />);
// Tab buttons exist
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument();
// Only chat visible by default
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument();
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument();
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-003
it('mobile: clicking Notes tab switches to CollabNotes', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
fireEvent.click(screen.getByRole('button', { name: /notes/i }))
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
setViewport(375);
render(<CollabPanel tripId={1} />);
fireEvent.click(screen.getByRole('button', { name: /notes/i }));
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-004
it('mobile: clicking Polls tab switches to CollabPolls', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
fireEvent.click(screen.getByRole('button', { name: /polls/i }))
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
setViewport(375);
render(<CollabPanel tripId={1} />);
fireEvent.click(screen.getByRole('button', { name: /polls/i }));
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-005
it('mobile: clicking What\'s Next tab shows WhatsNextWidget', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }))
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it("mobile: clicking What's Next tab shows WhatsNextWidget", () => {
setViewport(375);
render(<CollabPanel tripId={1} />);
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }));
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-006
it('mobile: active tab button has accent background style', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
const chatButton = screen.getByRole('button', { name: /chat/i })
expect(chatButton.style.background).toBe('var(--accent)')
const notesButton = screen.getByRole('button', { name: /notes/i })
expect(notesButton.style.background).toBe('transparent')
})
setViewport(375);
render(<CollabPanel tripId={1} />);
const chatButton = screen.getByRole('button', { name: /chat/i });
expect(chatButton.style.background).toBe('var(--accent)');
const notesButton = screen.getByRole('button', { name: /notes/i });
expect(notesButton.style.background).toBe('transparent');
});
// FE-COMP-COLLABPANEL-007
it('mobile: default active tab is Chat', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
})
setViewport(375);
render(<CollabPanel tripId={1} />);
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-008
it('tripMembers prop is forwarded to WhatsNextWidget', () => {
setViewport(1280)
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />)
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
})
setViewport(1280);
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />);
expect(screen.getByTestId('whats-next')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-009
it('tripId prop is forwarded to child components', () => {
setViewport(1280)
render(<CollabPanel tripId={1} />)
setViewport(1280);
render(<CollabPanel tripId={1} />);
// All children render without errors, confirming props were forwarded
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
})
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
expect(screen.getByTestId('collab-polls')).toBeInTheDocument();
});
// FE-COMP-COLLABPANEL-010
it('resize from desktop to mobile hides side-by-side layout', () => {
setViewport(1280)
const { rerender } = render(<CollabPanel tripId={1} />)
setViewport(1280);
const { rerender } = render(<CollabPanel tripId={1} />);
// All four panels visible on desktop
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.getByTestId('collab-notes')).toBeInTheDocument();
// Switch to mobile
setViewport(375)
rerender(<CollabPanel tripId={1} />)
setViewport(375);
rerender(<CollabPanel tripId={1} />);
// Tab bar appears, only chat visible
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
})
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument();
expect(screen.getByTestId('collab-chat')).toBeInTheDocument();
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument();
});
});
+205 -79
View File
@@ -1,112 +1,238 @@
import { useState, useEffect } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
import CollabChat from './CollabChat'
import CollabNotes from './CollabNotes'
import CollabPolls from './CollabPolls'
import WhatsNextWidget from './WhatsNextWidget'
import { BarChart3, MessageCircle, Sparkles, StickyNote } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import CollabChat from './CollabChat';
import CollabNotes from './CollabNotes';
import CollabPolls from './CollabPolls';
import WhatsNextWidget from './WhatsNextWidget';
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint);
useEffect(() => {
const check = () => setIsDesktop(window.innerWidth >= breakpoint)
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [breakpoint])
return isDesktop
const check = () => setIsDesktop(window.innerWidth >= breakpoint);
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, [breakpoint]);
return isDesktop;
}
const card = {
display: 'flex', flexDirection: 'column',
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
overflow: 'hidden', minHeight: 0,
}
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-card)',
borderRadius: 16,
border: '1px solid var(--border-faint)',
overflow: 'hidden',
minHeight: 0,
};
interface TripMember {
id: number
username: string
avatar_url?: string | null
id: number;
username: string;
avatar_url?: string | null;
}
interface CollabFeatures {
chat: boolean;
notes: boolean;
polls: boolean;
whatsnext: boolean;
}
interface CollabPanelProps {
tripId: number
tripMembers?: TripMember[]
tripId: number;
tripMembers?: TripMember[];
collabFeatures?: CollabFeatures;
}
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
const { user } = useAuthStore()
const { t } = useTranslation()
const [mobileTab, setMobileTab] = useState('chat')
const isDesktop = useIsDesktop()
const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
{
id: 'next',
featureKey: 'whatsnext' as const,
labelKey: 'collab.whatsNext.title',
fallback: "What's Next",
icon: Sparkles,
},
];
const tabs = [
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
const { user } = useAuthStore();
const { t } = useTranslation();
const isDesktop = useIsDesktop();
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true };
const tabs = useMemo(
() =>
ALL_TABS.filter((tab) => features[tab.featureKey]).map((tab) => ({
...tab,
label: t(tab.labelKey) || tab.fallback,
})),
[features, t]
);
const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat');
// If active tab gets disabled, switch to first available
useEffect(() => {
if (tabs.length > 0 && !tabs.some((t) => t.id === mobileTab)) {
setMobileTab(tabs[0].id);
}
}, [tabs, mobileTab]);
const chatOn = features.chat;
const rightPanels = [features.notes && 'notes', features.polls && 'polls', features.whatsnext && 'whatsnext'].filter(
Boolean
) as string[];
if (tabs.length === 0) return null;
if (isDesktop) {
// Chat always 380px fixed when on. Right panels share remaining space.
// If chat off, all panels share full width equally.
if (chatOn && rightPanels.length === 0) {
// Only chat
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
</div>
);
}
if (chatOn) {
// Chat left (380px) + right panels
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: '0 0 380px' }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{rightPanels.length === 1 && (
<div style={{ ...card, flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 &&
rightPanels.map((p) => (
<div key={p} style={{ ...card, flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
{rightPanels.length === 3 && (
<>
<div style={{ ...card, flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div style={{ ...card, flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
</>
)}
</div>
</div>
);
}
// Chat off — remaining panels share full width
const panels = rightPanels;
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
);
}
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{/* Chat — left, fixed width */}
<div style={{ ...card, flex: '0 0 380px' }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
{/* Right column: Notes top, Polls + What's Next bottom */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{/* Notes — top */}
<div style={{ ...card, flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
{panels.map((p) => (
<div key={p} style={{ ...card, flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
{/* Polls + What's Next — bottom row */}
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div style={{ ...card, flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div style={{ ...card, flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
</div>
))}
</div>
)
);
}
// Mobile: tab bar + single panel
// Mobile: tab bar + single panel (only enabled tabs)
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
<div style={{
display: 'flex', gap: 2, padding: '8px 12px', borderBottom: '1px solid var(--border-faint)',
background: 'var(--bg-card)', flexShrink: 0,
}}>
{tabs.map(tab => {
const Icon = tab.icon
const active = mobileTab === tab.id
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
position: 'absolute',
inset: 0,
}}
>
<div
style={{
display: 'flex',
gap: 2,
padding: '8px 12px',
borderBottom: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
flexShrink: 0,
}}
>
{tabs.map((tab) => {
const active = mobileTab === tab.id;
return (
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
transition: 'all 0.15s',
}}>
<button
key={tab.id}
onClick={() => setMobileTab(tab.id)}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
padding: '8px 0',
borderRadius: 10,
border: 'none',
cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 11,
fontWeight: 600,
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
>
{tab.label}
</button>
)
);
})}
</div>
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
);
}
@@ -10,16 +10,16 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { addListener } from '../../api/websocket';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabPolls from './CollabPolls';
import { addListener } from '../../api/websocket';
const currentUser = buildUser({ id: 1, username: 'testuser' });
@@ -43,11 +43,7 @@ const defaultProps = { tripId: 1, currentUser };
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [] }),
),
);
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [] })));
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
});
@@ -63,31 +59,21 @@ describe('CollabPolls', () => {
http.get('/api/trips/1/collab/polls', async () => {
await new Promise((r) => setTimeout(r, 200));
return HttpResponse.json({ polls: [] });
}),
})
);
render(<CollabPolls {...defaultProps} />);
// The spinner is a div with animation style
expect(
document.querySelector('[style*="animation"]'),
).toBeInTheDocument();
expect(document.querySelector('[style*="animation"]')).toBeInTheDocument();
});
it('FE-COMP-POLLS-003: renders poll question from API', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll()] }),
),
);
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })));
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
});
it('FE-COMP-POLLS-004: renders poll options', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll()] }),
),
);
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })));
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
expect(screen.getByText('Rome')).toBeInTheDocument();
@@ -97,9 +83,7 @@ describe('CollabPolls', () => {
render(<CollabPolls {...defaultProps} />);
// Wait for loading to finish
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(
screen.getByRole('button', { name: /new/i }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
});
it('FE-COMP-POLLS-006: clicking New Poll button opens the create modal', async () => {
@@ -140,8 +124,8 @@ describe('CollabPolls', () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/polls', () =>
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) }),
),
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) })
)
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
@@ -159,20 +143,23 @@ describe('CollabPolls', () => {
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
let voteCalled = false;
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll()] }),
),
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll()] })),
http.post('/api/trips/1/collab/polls/1/vote', () => {
voteCalled = true;
return HttpResponse.json({
poll: buildPoll({
options: [
{ id: 1, text: 'Paris', label: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
{
id: 1,
text: 'Paris',
label: 'Paris',
voters: [{ user_id: 1, username: 'testuser', avatar_url: null }],
},
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
],
}),
});
}),
})
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
@@ -183,9 +170,7 @@ describe('CollabPolls', () => {
it('FE-COMP-POLLS-010: closed poll shows "Closed" badge', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
),
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }))
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/closed/i);
@@ -193,9 +178,7 @@ describe('CollabPolls', () => {
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
),
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }))
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
@@ -206,13 +189,11 @@ describe('CollabPolls', () => {
it('FE-COMP-POLLS-012: delete button calls DELETE API and removes poll', async () => {
let deleteCalled = false;
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ id: 5 })] }),
),
http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ id: 5 })] })),
http.delete('/api/trips/1/collab/polls/5', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
}),
})
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
@@ -223,9 +204,7 @@ describe('CollabPolls', () => {
await user.click(deleteBtn);
await waitFor(() => expect(deleteCalled).toBe(true));
await waitFor(() =>
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
);
await waitFor(() => expect(screen.queryByText('Best destination?')).not.toBeInTheDocument());
});
it('FE-COMP-POLLS-013: WebSocket collab:poll:created event adds poll', async () => {
@@ -240,20 +219,14 @@ describe('CollabPolls', () => {
});
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ id: 3 })] }),
),
);
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json({ polls: [buildPoll({ id: 3 })] })));
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:deleted', pollId: 3 });
await waitFor(() =>
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
);
await waitFor(() => expect(screen.queryByText('Best destination?')).not.toBeInTheDocument());
});
it('FE-COMP-POLLS-015: adding a third option in create modal', async () => {
File diff suppressed because it is too large Load Diff
@@ -1,27 +1,27 @@
import { render, screen } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import WhatsNextWidget from './WhatsNextWidget'
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import WhatsNextWidget from './WhatsNextWidget';
// Dynamic date helpers
const today = new Date().toISOString().split('T')[0]
const today = new Date().toISOString().split('T')[0];
function getFutureDate(daysAhead: number): string {
const d = new Date()
d.setDate(d.getDate() + daysAhead)
return d.toISOString().split('T')[0]
const d = new Date();
d.setDate(d.getDate() + daysAhead);
return d.toISOString().split('T')[0];
}
function getPastDate(daysBack: number): string {
const d = new Date()
d.setDate(d.getDate() - daysBack)
return d.toISOString().split('T')[0]
const d = new Date();
d.setDate(d.getDate() - daysBack);
return d.toISOString().split('T')[0];
}
const tomorrow = getFutureDate(1)
const yesterday = getPastDate(1)
const tomorrow = getFutureDate(1);
const yesterday = getPastDate(1);
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []) {
return {
@@ -51,70 +51,85 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
...placeOverrides,
},
participants,
}
};
}
describe('WhatsNextWidget', () => {
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
})
resetAllStores();
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
});
afterEach(() => {
resetAllStores()
})
resetAllStores();
});
it('FE-COMP-WHATSNEXT-001: renders empty state when no days exist', () => {
seedStore(useTripStore, { days: [], assignments: {} })
render(<WhatsNextWidget />)
seedStore(useTripStore, { days: [], assignments: {} });
render(<WhatsNextWidget />);
// Translation resolves to "No upcoming activities"
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument()
expect(screen.queryByText('Place 1')).toBeNull()
})
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument();
expect(screen.queryByText('Place 1')).toBeNull();
});
it('FE-COMP-WHATSNEXT-001b: empty state element is rendered', () => {
seedStore(useTripStore, { days: [], assignments: {} })
render(<WhatsNextWidget />)
seedStore(useTripStore, { days: [], assignments: {} });
render(<WhatsNextWidget />);
// collab.whatsNext.empty key is rendered as text in test env
const allText = document.body.textContent || ''
const allText = document.body.textContent || '';
// No assignment time/name visible — just the header and empty hint
expect(allText).not.toContain('14:30')
})
expect(allText).not.toContain('14:30');
});
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{
id: 1,
trip_id: 1,
date: yesterday,
title: 'Old Day',
order: 0,
assignments: [],
notes_items: [],
notes: null,
},
],
assignments: {
'1': [makeAssignment(10, { place_time: '08:00' })],
},
})
render(<WhatsNextWidget />)
expect(screen.queryByText('08:00')).toBeNull()
expect(screen.queryByText('Place 10')).toBeNull()
})
});
render(<WhatsNextWidget />);
expect(screen.queryByText('08:00')).toBeNull();
expect(screen.queryByText('Place 10')).toBeNull();
});
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(21, { name: 'Museum' })],
},
})
render(<WhatsNextWidget />)
});
render(<WhatsNextWidget />);
// The label text comes from t('collab.whatsNext.tomorrow') which falls back to 'Tomorrow'
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument()
})
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
seedStore(useTripStore, {
@@ -122,56 +137,64 @@ describe('WhatsNextWidget', () => {
assignments: {
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText(/today/i)).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText(/today/i)).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('14:30')).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText('14:30')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('2:30 PM')).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText('2:30 PM')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('TBD')).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText('TBD')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-010: caps list at 8 items', () => {
const days = Array.from({ length: 5 }, (_, i) => ({
@@ -183,96 +206,106 @@ describe('WhatsNextWidget', () => {
assignments: [],
notes_items: [],
notes: null,
}))
}));
const assignments: Record<string, unknown[]> = {}
let placeId = 100
const assignments: Record<string, unknown[]> = {};
let placeId = 100;
for (const day of days) {
assignments[String(day.id)] = [
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '10:00' }),
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '11:00' }),
]
];
}
seedStore(useTripStore, { days, assignments })
render(<WhatsNextWidget />)
seedStore(useTripStore, { days, assignments });
render(<WhatsNextWidget />);
// 10 items seeded, only 8 should appear — count "TBD" or time occurrences
const timeElements = screen.getAllByText('10:00')
const timeElements = screen.getAllByText('10:00');
// At most 4 days * 1 morning slot = up to 4 "10:00" entries, but capped at 8 total items
// We verify total rendered items is at most 8 by counting both time slots
const allTimes = screen.getAllByText(/10:00|11:00/)
expect(allTimes.length).toBeLessThanOrEqual(8)
})
const allTimes = screen.getAllByText(/10:00|11:00/);
expect(allTimes.length).toBeLessThanOrEqual(8);
});
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('alice')).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText('alice')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(41, { name: 'Park' }, [])],
},
})
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />)
expect(screen.getByText('bob')).toBeInTheDocument()
})
});
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />);
expect(screen.getByText('bob')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('19:00')).toBeInTheDocument()
expect(screen.getByText('21:30')).toBeInTheDocument()
})
});
render(<WhatsNextWidget />);
expect(screen.getByText('19:00')).toBeInTheDocument();
expect(screen.getByText('21:30')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
makeAssignment(61, { name: 'Lunch', place_time: '12:00' }),
],
},
})
render(<WhatsNextWidget />)
const tomorrowHeaders = screen.getAllByText(/tomorrow/i)
});
render(<WhatsNextWidget />);
const tomorrowHeaders = screen.getAllByText(/tomorrow/i);
// Only one day header for tomorrow
expect(tomorrowHeaders).toHaveLength(1)
expect(screen.getByText('Breakfast')).toBeInTheDocument()
expect(screen.getByText('Lunch')).toBeInTheDocument()
})
expect(tomorrowHeaders).toHaveLength(1);
expect(screen.getByText('Breakfast')).toBeInTheDocument();
expect(screen.getByText('Lunch')).toBeInTheDocument();
});
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
// If it's not midnight, a past-time event today should not appear
const now = new Date()
const now = new Date();
if (now.getHours() > 0) {
const pastTime = '00:01' // Very early — will be past for most of the day
const pastTime = '00:01'; // Very early — will be past for most of the day
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
days: [
{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null },
],
assignments: {
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
},
})
render(<WhatsNextWidget />)
});
render(<WhatsNextWidget />);
// If current time > 00:01, the item should not appear
if (now.getHours() > 0 || now.getMinutes() > 1) {
expect(screen.queryByText('Early Bird')).toBeNull()
expect(screen.queryByText('Early Bird')).toBeNull();
}
}
})
})
});
});
+215 -89
View File
@@ -1,61 +1,66 @@
import React, { useMemo } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
import { Calendar, MapPin, Sparkles } from 'lucide-react';
import React, { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
function formatTime(timeStr, is12h) {
if (!timeStr) return ''
const [h, m] = timeStr.split(':').map(Number)
if (!timeStr) return '';
const [h, m] = timeStr.split(':').map(Number);
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
const period = h >= 12 ? 'PM' : 'AM';
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${String(m).padStart(2, '0')} ${period}`;
}
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function formatDayLabel(date, t, locale) {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
const now = new Date();
const nowDate = now.toISOString().split('T')[0];
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0];
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
if (date === nowDate) return t('collab.whatsNext.today') || 'Today';
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow';
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
interface TripMember {
id: number
username: string
avatar_url?: string | null
id: number;
username: string;
avatar_url?: string | null;
}
interface WhatsNextWidgetProps {
tripMembers?: TripMember[]
tripMembers?: TripMember[];
}
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
const { days, assignments } = useTripStore()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const { days, assignments } = useTripStore();
const { t, locale } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const upcoming = useMemo(() => {
const now = new Date()
const nowDate = now.toISOString().split('T')[0]
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const items = []
const now = new Date();
const nowDate = now.toISOString().split('T')[0];
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const items = [];
for (const day of (days || [])) {
if (!day.date) continue
const dayAssignments = assignments[String(day.id)] || []
for (const day of days || []) {
if (!day.date) continue;
const dayAssignments = assignments[String(day.id)] || [];
for (const a of dayAssignments) {
if (!a.place) continue
if (!a.place) continue;
// Include: today (future times) + all future days
const isFutureDay = day.date > nowDate
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime)
const isFutureDay = day.date > nowDate;
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime);
if (isFutureDay || isTodayFuture) {
items.push({
id: a.id,
@@ -65,32 +70,47 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
date: day.date,
dayTitle: day.title,
category: a.place.category,
participants: (a.participants && a.participants.length > 0)
? a.participants
: tripMembers.map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
participants:
a.participants && a.participants.length > 0
? a.participants
: tripMembers.map((m) => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
address: a.place.address,
})
});
}
}
}
items.sort((a, b) => {
const da = a.date + (a.time || '99:99')
const db = b.date + (b.time || '99:99')
return da.localeCompare(db)
})
const da = a.date + (a.time || '99:99');
const db = b.date + (b.time || '99:99');
return da.localeCompare(db);
});
return items.slice(0, 8)
}, [days, assignments, tripMembers])
return items.slice(0, 8);
}, [days, assignments, tripMembers]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
}}>
<div
style={{
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: 7,
flexShrink: 0,
}}
>
<Sparkles size={14} color="var(--text-faint)" />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
<span
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-muted)',
letterSpacing: 0.3,
textTransform: 'uppercase',
}}
>
{t('collab.whatsNext.title') || "What's Next"}
</span>
</div>
@@ -98,48 +118,104 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{/* List */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
{upcoming.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '48px 20px',
textAlign: 'center',
}}
>
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('collab.whatsNext.empty')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{upcoming.map((item, idx) => {
const prevItem = upcoming[idx - 1]
const showDayHeader = !prevItem || prevItem.date !== item.date
const prevItem = upcoming[idx - 1];
const showDayHeader = !prevItem || prevItem.date !== item.date;
return (
<React.Fragment key={item.id}>
{showDayHeader && (
<div style={{
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
textTransform: 'uppercase', letterSpacing: 0.5,
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
}}>
<div
style={{
fontSize: 10,
fontWeight: 500,
color: 'var(--text-faint)',
textTransform: 'uppercase',
letterSpacing: 0.5,
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
}}
>
{formatDayLabel(item.date, t, locale)}
{item.dayTitle ? `${item.dayTitle}` : ''}
</div>
)}
<div style={{
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
background: 'var(--bg-secondary)', transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
<div
style={{
display: 'flex',
gap: 10,
padding: '8px 10px',
borderRadius: 10,
background: 'var(--bg-secondary)',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-secondary)')}
>
{/* Time column */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minWidth: 44,
flexShrink: 0,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
lineHeight: 1,
}}
>
{item.time ? formatTime(item.time, is12h) : 'TBD'}
</span>
{item.endTime && (
<>
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
<span
style={{
fontSize: 7,
color: 'var(--text-faint)',
fontWeight: 600,
letterSpacing: 0.3,
margin: '2px 0',
textTransform: 'uppercase',
}}
>
{t('collab.whatsNext.until') || 'bis'}
</span>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
lineHeight: 1,
}}
>
{formatTime(item.endTime, is12h)}
</span>
</>
@@ -147,17 +223,43 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
</div>
{/* Divider */}
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
<div
style={{
width: 1,
alignSelf: 'stretch',
background: 'var(--border-faint)',
flexShrink: 0,
margin: '2px 0',
}}
/>
{/* Details */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name}
</div>
{item.address && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<span
style={{
fontSize: 10,
color: 'var(--text-faint)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.address}
</span>
</div>
@@ -166,23 +268,47 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
{/* Participants */}
{item.participants.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
{item.participants.map(p => (
<div key={p.user_id} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
}}>
<div style={{
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
overflow: 'hidden', flexShrink: 0,
}}>
{p.avatar
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: p.username?.[0]?.toUpperCase()
}
{item.participants.map((p) => (
<div
key={p.user_id}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px 2px 3px',
borderRadius: 99,
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-faint)',
}}
>
<div
style={{
width: 16,
height: 16,
borderRadius: '50%',
background: 'var(--bg-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 7,
fontWeight: 700,
color: 'var(--text-muted)',
overflow: 'hidden',
flexShrink: 0,
}}
>
{p.avatar ? (
<img
src={`/uploads/avatars/${p.avatar}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
p.username?.[0]?.toUpperCase()
)}
</div>
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>
{p.username}
</span>
</div>
))}
</div>
@@ -190,11 +316,11 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
</div>
</div>
</React.Fragment>
)
);
})}
</div>
)}
</div>
</div>
)
);
}
@@ -1,81 +1,259 @@
import { useState, useEffect, useCallback } from 'react'
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { ArrowRightLeft, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import CustomSelect from '../shared/CustomSelect';
const CURRENCIES = [
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
]
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'FOK',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KID',
'KMF',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRU',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLE',
'SOS',
'SRD',
'SSP',
'STN',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TVD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XCD',
'XDR',
'XOF',
'XPF',
'YER',
'ZAR',
'ZMW',
'ZWL',
];
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
const CURRENCY_OPTIONS = CURRENCIES.map((c) => ({ value: c, label: c }));
export default function CurrencyWidget() {
const { t, locale } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100')
const [rate, setRate] = useState(null)
const [loading, setLoading] = useState(false)
const { t, locale } = useTranslation();
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR');
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD');
const [amount, setAmount] = useState('100');
const [rate, setRate] = useState(null);
const [loading, setLoading] = useState(false);
const fetchRate = useCallback(async () => {
if (from === to) { setRate(1); return }
setLoading(true)
if (from === to) {
setRate(1);
return;
}
setLoading(true);
try {
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
const data = await resp.json()
setRate(data.rates?.[to] || null)
} catch { setRate(null) }
finally { setLoading(false) }
}, [from, to])
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`);
const data = await resp.json();
setRate(data.rates?.[to] || null);
} catch {
setRate(null);
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => { fetchRate() }, [fetchRate])
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
useEffect(() => {
fetchRate();
}, [fetchRate]);
useEffect(() => {
localStorage.setItem('currency_from', from);
}, [from]);
useEffect(() => {
localStorage.setItem('currency_to', to);
}, [to]);
const swap = () => { setFrom(to); setTo(from) }
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
const swap = () => {
setFrom(to);
setTo(from);
};
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null;
const formatNumber = (num) => {
if (!num || num === '—') return '—'
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const result = rawResult
if (!num || num === '—') return '—';
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const result = rawResult;
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<div
className="rounded-2xl border p-4"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>
{t('dashboard.currency')}
</span>
<button onClick={fetchRate} className="rounded-md p-1 transition-colors" style={{ color: 'var(--text-faint)' }}>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Amount */}
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<div
className="mb-3 rounded-xl px-4 py-3"
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}
>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
onChange={(e) => setAmount(e.target.value)}
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
/>
</div>
{/* From / Swap / To */}
<div className="flex items-center gap-2 mb-3">
<div className="mb-3 flex items-center gap-2">
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
<button
onClick={swap}
className="shrink-0 rounded-lg p-1.5 transition-colors"
style={{ color: 'var(--text-muted)' }}
>
<ArrowRightLeft size={13} />
</button>
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
@@ -86,10 +264,17 @@ export default function CurrencyWidget() {
{/* Result */}
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
{formatNumber(result)}{' '}
<span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>
{to}
</span>
</p>
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
{rate && (
<p className="mt-0.5 text-[10px]" style={{ color: 'var(--text-faint)' }}>
1 {from} = {rate.toFixed(4)} {to}
</p>
)}
</div>
</div>
)
);
}
@@ -1,149 +1,149 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TimezoneWidget from './TimezoneWidget'
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import TimezoneWidget from './TimezoneWidget';
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
localStorage.clear()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
resetAllStores();
vi.clearAllMocks();
localStorage.clear();
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any);
});
describe('TimezoneWidget', () => {
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
render(<TimezoneWidget />)
expect(document.body).toBeInTheDocument()
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
render(<TimezoneWidget />);
expect(document.body).toBeInTheDocument();
expect(screen.getByText('New York')).toBeInTheDocument();
expect(screen.getByText('Tokyo')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
render(<TimezoneWidget />)
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
expect(timeElements.length).toBeGreaterThan(0)
})
render(<TimezoneWidget />);
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/);
expect(timeElements.length).toBeGreaterThan(0);
});
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
render(<TimezoneWidget />)
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
})
render(<TimezoneWidget />);
expect(screen.getByText(/timezones/i)).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
localStorage.clear()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
localStorage.clear();
render(<TimezoneWidget />);
expect(screen.getByText('New York')).toBeInTheDocument();
expect(screen.getByText('Tokyo')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
render(<TimezoneWidget />)
expect(screen.getByText('Berlin')).toBeInTheDocument()
expect(screen.queryByText('New York')).toBeNull()
})
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]));
render(<TimezoneWidget />);
expect(screen.getByText('Berlin')).toBeInTheDocument();
expect(screen.queryByText('New York')).toBeNull();
});
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
})
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const user = userEvent.setup();
render(<TimezoneWidget />);
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
// Find and click Berlin in the popular zones list
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
expect(screen.getByText('Berlin')).toBeInTheDocument()
const berlinButton = await screen.findByRole('button', { name: /Berlin/i });
await user.click(berlinButton);
expect(screen.getByText('Berlin')).toBeInTheDocument();
// Panel should be closed
expect(screen.queryByText('Custom Timezone')).toBeNull()
})
expect(screen.queryByText('Custom Timezone')).toBeNull();
});
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const user = userEvent.setup();
render(<TimezoneWidget />);
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
// Type label and timezone
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'My City')
await user.type(tzInput, 'Europe/Paris')
const labelInput = screen.getByPlaceholderText('Label (optional)');
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(labelInput, 'My City');
await user.type(tzInput, 'Europe/Paris');
// Click Add
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText('My City')).toBeInTheDocument()
})
const addButton = screen.getByRole('button', { name: 'Add' });
await user.click(addButton);
expect(await screen.findByText('My City')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'Invalid/Timezone')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
})
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(tzInput, 'Invalid/Timezone');
const addButton = screen.getByRole('button', { name: 'Add' });
await user.click(addButton);
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const user = userEvent.setup();
render(<TimezoneWidget />);
// Default zones include New York (America/New_York)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'America/New_York')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
})
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(tzInput, 'America/New_York');
const addButton = screen.getByRole('button', { name: 'Add' });
await user.click(addButton);
expect(await screen.findByText(/already added/i)).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
const user = userEvent.setup();
render(<TimezoneWidget />);
expect(screen.getByText('New York')).toBeInTheDocument();
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
// Remove buttons for New York and Tokyo come after the Plus button
const allButtons = screen.getAllByRole('button')
const allButtons = screen.getAllByRole('button');
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
await user.click(allButtons[1])
expect(screen.queryByText('New York')).toBeNull()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
await user.click(allButtons[1]);
expect(screen.queryByText('New York')).toBeNull();
expect(screen.getByText('Tokyo')).toBeInTheDocument();
});
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
})
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const berlinButton = await screen.findByRole('button', { name: /Berlin/i });
await user.click(berlinButton);
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]');
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true);
});
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'Singapore')
await user.type(tzInput, 'Asia/Singapore')
await user.keyboard('{Enter}')
expect(await screen.findByText('Singapore')).toBeInTheDocument()
})
})
const user = userEvent.setup();
render(<TimezoneWidget />);
const allButtons = screen.getAllByRole('button');
await user.click(allButtons[0]);
const labelInput = screen.getByPlaceholderText('Label (optional)');
const tzInput = screen.getByPlaceholderText('e.g. America/New_York');
await user.type(labelInput, 'Singapore');
await user.type(tzInput, 'Asia/Singapore');
await user.keyboard('{Enter}');
expect(await screen.findByText('Singapore')).toBeInTheDocument();
});
});
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { Plus, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' },
@@ -22,103 +22,147 @@ const POPULAR_ZONES = [
{ label: 'Seoul', tz: 'Asia/Seoul' },
{ label: 'Moscow', tz: 'Europe/Moscow' },
{ label: 'Cairo', tz: 'Africa/Cairo' },
]
];
function getTime(tz, locale, is12h) {
try {
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
} catch { return '—' }
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h });
} catch {
return '—';
}
}
function getOffset(tz) {
try {
const now = new Date()
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
const diff = (remote - local) / 3600000
const sign = diff >= 0 ? '+' : ''
return `${sign}${diff}h`
} catch { return '' }
const now = new Date();
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }));
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }));
const diff = (remote - local) / 3600000;
const sign = diff >= 0 ? '+' : '';
return `${sign}${diff}h`;
} catch {
return '';
}
}
export default function TimezoneWidget() {
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const { t, locale } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
]
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
const [customLabel, setCustomLabel] = useState('')
const [customTz, setCustomTz] = useState('')
const [customError, setCustomError] = useState('')
const saved = localStorage.getItem('dashboard_timezones');
return saved
? JSON.parse(saved)
: [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
];
});
const [now, setNow] = useState(Date.now());
const [showAdd, setShowAdd] = useState(false);
const [customLabel, setCustomLabel] = useState('');
const [customTz, setCustomTz] = useState('');
const [customError, setCustomError] = useState('');
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
return () => clearInterval(i)
}, [])
const i = setInterval(() => setNow(Date.now()), 10000);
return () => clearInterval(i);
}, []);
useEffect(() => {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
localStorage.setItem('dashboard_timezones', JSON.stringify(zones));
}, [zones]);
const isValidTz = (tz: string) => {
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
}
try {
Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date());
return true;
} catch {
return false;
}
};
const addCustomZone = () => {
const tz = customTz.trim()
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
setZones([...zones, { label, tz }])
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
}
const tz = customTz.trim();
if (!tz) {
setCustomError(t('dashboard.timezoneCustomErrorEmpty'));
return;
}
if (!isValidTz(tz)) {
setCustomError(t('dashboard.timezoneCustomErrorInvalid'));
return;
}
if (zones.find((z) => z.tz === tz)) {
setCustomError(t('dashboard.timezoneCustomErrorDuplicate'));
return;
}
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz;
setZones([...zones, { label, tz }]);
setCustomLabel('');
setCustomTz('');
setCustomError('');
setShowAdd(false);
};
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
if (!zones.find((z) => z.tz === zone.tz)) {
setZones([...zones, zone]);
}
setShowAdd(false)
}
setShowAdd(false);
};
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const removeZone = (tz) => setZones(zones.filter((z) => z.tz !== tz));
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h });
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const localZone = rawZone.split('/').pop().replace(/_/g, ' ');
// Show abbreviated timezone name (e.g. CET, CEST, EST)
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<div
className="rounded-2xl border p-4"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}
>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>
{t('dashboard.timezone')}
</span>
<button
onClick={() => setShowAdd(!showAdd)}
className="rounded-md p-1 transition-colors"
style={{ color: 'var(--text-faint)' }}
>
<Plus size={12} />
</button>
</div>
{/* Local time */}
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{localTime}
</p>
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>
{localZone} ({tzAbbr}) · {t('dashboard.localTime')}
</p>
</div>
{/* Zone list */}
<div className="space-y-2">
{zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group">
{zones.map((z) => (
<div key={z.tz} className="group flex items-center justify-between">
<div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>
{getTime(z.tz, locale, is12h)}
</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>
{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span>
</p>
</div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
<button
onClick={() => removeZone(z.tz)}
className="rounded p-1 opacity-0 transition-all group-hover:opacity-100"
style={{ color: 'var(--text-faint)' }}
>
<X size={11} />
</button>
</div>
@@ -127,41 +171,76 @@ export default function TimezoneWidget() {
{/* Add zone dropdown */}
{showAdd && (
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
<div className="mt-2 max-h-[280px] overflow-auto rounded-xl p-2" style={{ background: 'var(--bg-secondary)' }}>
{/* Custom timezone */}
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
<div className="mb-2 rounded-lg px-2 py-2" style={{ background: 'var(--bg-card)' }}>
<p
className="mb-2 text-[10px] font-semibold uppercase tracking-wide"
style={{ color: 'var(--text-faint)' }}
>
{t('dashboard.timezoneCustomTitle')}
</p>
<div className="space-y-1.5">
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
<input
value={customLabel}
onChange={(e) => setCustomLabel(e.target.value)}
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
className="w-full rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border-secondary)',
}}
/>
<input
value={customTz}
onChange={(e) => {
setCustomTz(e.target.value);
setCustomError('');
}}
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
<button onClick={addCustomZone}
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
className="w-full rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}`,
}}
onKeyDown={(e) => {
if (e.key === 'Enter') addCustomZone();
}}
/>
{customError && (
<p className="text-[10px]" style={{ color: '#ef4444' }}>
{customError}
</p>
)}
<button
onClick={addCustomZone}
className="w-full rounded-lg py-1.5 text-xs font-medium transition-colors"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}
>
{t('dashboard.timezoneCustomAdd')}
</button>
</div>
</div>
{/* Popular zones */}
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
<button key={z.tz} onClick={() => addZone(z)}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
{POPULAR_ZONES.filter((z) => !zones.find((existing) => existing.tz === z.tz)).map((z) => (
<button
key={z.tz}
onClick={() => addZone(z)}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left text-xs transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>
{getTime(z.tz, locale, is12h)}
</span>
</button>
))}
</div>
)}
</div>
)
);
}
@@ -1,12 +1,12 @@
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import FileManager from './FileManager';
// Mock getAuthUrl
@@ -81,7 +81,7 @@ beforeEach(() => {
return HttpResponse.json({ files: [] });
}
return HttpResponse.json({ files: [] });
}),
})
);
// Stub window.confirm
@@ -144,7 +144,8 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
// filesApi.list is mocked — configure it to return trash files when called with trash=true
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
if (trash)
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
@@ -161,7 +162,8 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
if (trash)
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
@@ -182,7 +184,8 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => {
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
if (trash)
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
@@ -202,7 +205,8 @@ describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-009: empty trash calls filesApi.emptyTrash', async () => {
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
if (trash)
return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
@@ -221,9 +225,7 @@ describe('FileManager', () => {
});
it('FE-COMP-FILEMANAGER-010: image file click opens lightbox', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
];
const files = [buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
@@ -238,9 +240,7 @@ describe('FileManager', () => {
});
it('FE-COMP-FILEMANAGER-011: escape key closes lightbox', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
];
const files = [buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
@@ -382,7 +382,7 @@ describe('FileManager', () => {
// Close via X button in the modal (second X button — first might be something else)
const closeButtons = screen.getAllByRole('button', { name: '' });
// Find a close button near the modal header — click the last X-like button
const xBtn = closeButtons.find(btn => btn.closest('[style*="z-index: 10000"]'));
const xBtn = closeButtons.find((btn) => btn.closest('[style*="z-index: 10000"]'));
if (xBtn) await user.click(xBtn);
});
@@ -489,7 +489,9 @@ describe('FileManager', () => {
const day = buildDay({ id: 5, date: '2025-06-01', day_number: 1 });
const assignments = { '5': [{ id: 1, day_id: 5, place_id: 10, order_index: 0, place }] };
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />);
render(
<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />
);
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import JournalBody from './JournalBody';
+57 -31
View File
@@ -1,20 +1,23 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import ReactMarkdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
interface Props {
text: string
dark?: boolean
text: string;
dark?: boolean;
}
export default function JournalBody({ text, dark }: Props) {
return (
<div className="journal-body" style={{
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 1.6,
color: 'inherit',
}}>
<div
className="journal-body"
style={{
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 1.6,
color: 'inherit',
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
@@ -23,15 +26,25 @@ export default function JournalBody({ text, dark }: Props) {
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: `3px solid var(--journal-accent)`,
paddingLeft: 16, margin: '12px 0',
fontStyle: 'italic', color: 'var(--journal-muted)',
}}>{children}</blockquote>
<blockquote
style={{
borderLeft: `3px solid var(--journal-accent)`,
paddingLeft: 16,
margin: '12px 0',
fontStyle: 'italic',
color: 'var(--journal-muted)',
}}
>
{children}
</blockquote>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
{children}
</a>
),
@@ -42,29 +55,42 @@ export default function JournalBody({ text, dark }: Props) {
em: ({ children }) => <em>{children}</em>,
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
code: ({ children, className }) => {
const isBlock = className?.includes('language-')
const isBlock = className?.includes('language-');
if (isBlock) {
return (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
}}>
<pre
style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8,
padding: 14,
overflowX: 'auto',
fontSize: 13,
fontFamily: 'monospace',
margin: '12px 0',
}}
>
<code>{children}</code>
</pre>
)
);
}
return (
<code style={{
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
}}>{children}</code>
)
<code
style={{
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
borderRadius: 4,
padding: '2px 5px',
fontSize: '0.9em',
fontFamily: 'monospace',
}}
>
{children}
</code>
);
},
}}
>
{text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
</ReactMarkdown>
</div>
)
);
}
@@ -48,14 +48,14 @@ vi.mock('leaflet', () => {
};
});
import L from 'leaflet';
import React from 'react';
import { buildSettings } from '../../../tests/helpers/factories';
import { render } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
import type { JourneyMapHandle } from './JourneyMap';
import JourneyMap from './JourneyMap';
const entriesWithCoords = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
@@ -66,10 +66,7 @@ const entriesWithoutCoords = [
{ id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' },
];
const mixedEntries = [
...entriesWithCoords,
...entriesWithoutCoords,
];
const mixedEntries = [...entriesWithCoords, ...entriesWithoutCoords];
beforeEach(() => {
resetAllStores();
@@ -79,64 +76,47 @@ beforeEach(() => {
describe('JourneyMap', () => {
it('FE-COMP-JOURNEYMAP-001: renders map container', () => {
const { container } = render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
const { container } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
// The component renders a div with a child div ref for the Leaflet map
expect(container.firstChild).toBeInTheDocument();
expect(L.map).toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
// Two entries with valid lat/lng should produce two markers
expect(L.marker).toHaveBeenCalledTimes(2);
});
it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
);
render(<JourneyMap checkins={[]} entries={entriesWithoutCoords} />);
// Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng))
expect(L.marker).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
// With 2+ marker items, a route polyline is drawn
expect(L.polyline).toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
// Each marker calls bindTooltip with the entry label
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
'Paris',
expect.objectContaining({ direction: 'top' }),
);
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith('Paris', expect.objectContaining({ direction: 'top' }));
});
it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => {
const ref = React.createRef<JourneyMapHandle>();
render(
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
);
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
expect(ref.current).not.toBeNull();
expect(typeof ref.current!.focusMarker).toBe('function');
expect(typeof ref.current!.highlightMarker).toBe('function');
});
it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
// Each marker is created with L.divIcon containing SVG html
expect(L.divIcon).toHaveBeenCalledTimes(2);
const firstCall = (L.divIcon as any).mock.calls[0][0];
@@ -151,22 +131,14 @@ describe('JourneyMap', () => {
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' },
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' },
];
render(
<JourneyMap checkins={[]} entries={entriesWithMood} />
);
render(<JourneyMap checkins={[]} entries={entriesWithMood} />);
// Markers are still created (mood does not prevent rendering)
expect(L.marker).toHaveBeenCalledTimes(2);
// Tooltips use the entry titles
const mockMarker1 = (L.marker as any).mock.results[0].value;
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
'Happy Paris',
expect.objectContaining({ direction: 'top' }),
);
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith('Happy Paris', expect.objectContaining({ direction: 'top' }));
const mockMarker2 = (L.marker as any).mock.results[1].value;
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
'Sad Berlin',
expect.objectContaining({ direction: 'top' }),
);
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith('Sad Berlin', expect.objectContaining({ direction: 'top' }));
});
it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => {
@@ -175,9 +147,7 @@ describe('JourneyMap', () => {
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
{ id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' },
];
render(
<JourneyMap checkins={[]} entries={threeEntries} />
);
render(<JourneyMap checkins={[]} entries={threeEntries} />);
// Route polyline is drawn for items.length > 1
expect(L.polyline).toHaveBeenCalled();
const polylineCall = (L.polyline as any).mock.calls[0];
@@ -190,11 +160,12 @@ describe('JourneyMap', () => {
it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => {
// Trigger requestAnimationFrame synchronously
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
cb(0);
return 0;
};
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const mockMap = (L.map as any).mock.results[0].value;
// fitBounds is called inside requestAnimationFrame with the collected coordinates
@@ -208,9 +179,7 @@ describe('JourneyMap', () => {
const singleEntry = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' },
];
render(
<JourneyMap checkins={[]} entries={singleEntry} />
);
render(<JourneyMap checkins={[]} entries={singleEntry} />);
// One marker created
expect(L.marker).toHaveBeenCalledTimes(1);
// No route polyline — polyline is only drawn when items.length > 1
@@ -218,9 +187,7 @@ describe('JourneyMap', () => {
});
it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => {
const { container } = render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
const { container } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
// The component renders zoom in (+) and zoom out () buttons
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(2);
+229 -158
View File
@@ -1,42 +1,49 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import L from 'leaflet';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { useSettingsStore } from '../../store/settingsStore';
export interface MapMarkerItem {
id: string
lat: number
lng: number
label: string
mood?: string | null
time: string
id: string;
lat: number;
lng: number;
label: string;
mood?: string | null;
time: string;
dayColor: string;
dayLabel: number;
}
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
highlightMarker: (id: string | null) => void;
focusMarker: (id: string) => void;
invalidateSize: () => void;
}
interface MapEntry {
id: string
lat: number
lng: number
title?: string | null
mood?: string | null
entry_date: string
id: string;
lat: number;
lng: number;
title?: string | null;
mood?: string | null;
entry_date: string;
dayColor?: string;
dayLabel?: number;
}
interface Props {
checkins: any[]
entries: MapEntry[]
trail?: { lat: number; lng: number }[]
height?: number
dark?: boolean
activeMarkerId?: string | null
onMarkerClick?: (id: string, type?: string) => void
checkins: any[];
entries: MapEntry[];
trail?: { lat: number; lng: number }[];
height?: number;
dark?: boolean;
activeMarkerId?: string | null;
onMarkerClick?: (id: string, type?: string) => void;
fullScreen?: boolean;
paddingBottom?: number;
}
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const items: MapMarkerItem[] = []
const items: MapMarkerItem[] = [];
for (const e of entries) {
if (e.lat && e.lng) {
items.push({
@@ -46,258 +53,322 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
label: e.title || 'Entry',
mood: e.mood,
time: e.entry_date,
})
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
});
}
}
items.sort((a, b) => a.time.localeCompare(b.time))
return items
items.sort((a, b) => a.time.localeCompare(b.time));
return items;
}
const MARKER_W = 28
const MARKER_H = 36
const MARKER_W = 28;
const MARKER_H = 36;
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
const fill = dark
? (highlighted ? '#FAFAFA' : '#FAFAFA')
: (highlighted ? '#18181B' : '#18181B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = dark ? '#3F3F46' : '#fff'
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)';
const shadow = highlighted
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(index + 1)
const scale = highlighted ? 1.2 : 1
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))';
const label = String(dayLabel);
const scale = highlighted ? 1.2 : 1;
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>
</div>`
</div>`;
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRAIL: { lat: number; lng: number }[] = [];
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
ref
) {
const stableTrail = trail || EMPTY_TRAIL
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(new Map())
const itemsRef = useRef<MapMarkerItem[]>([])
const highlightedRef = useRef<string | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const stableTrail = trail || EMPTY_TRAIL;
const mapTileUrl = useSettingsStore((s) => s.settings.map_tile_url);
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const markersRef = useRef<Map<string, L.Marker>>(new Map());
const itemsRef = useRef<MapMarkerItem[]>([]);
const highlightedRef = useRef<string | null>(null);
const onMarkerClickRef = useRef(onMarkerClick);
onMarkerClickRef.current = onMarkerClick;
const darkRef = useRef(dark)
darkRef.current = dark
const darkRef = useRef(dark);
darkRef.current = dark;
const highlightMarker = useCallback((id: string | null) => {
const prev = highlightedRef.current
highlightedRef.current = id
const isDark = !!darkRef.current
const prev = highlightedRef.current;
highlightedRef.current = id;
const isDark = !!darkRef.current;
if (prev && prev !== id) {
const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev)
const marker = markersRef.current.get(prev);
const item = itemsRef.current.find((i) => i.id === prev);
if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, false, isDark),
}))
marker.setZIndexOffset(0)
marker.setIcon(
L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
})
);
marker.setZIndexOffset(0);
}
}
if (id) {
const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id)
const marker = markersRef.current.get(id);
const item = itemsRef.current.find((i) => i.id === id);
if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, true, isDark),
}))
marker.setZIndexOffset(1000)
marker.setIcon(
L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, true),
})
);
marker.setZIndexOffset(1000);
}
}
}, [])
}, []);
const focusMarker = useCallback((id: string) => {
highlightMarker(id)
const marker = markersRef.current.get(id)
highlightMarker(id);
const marker = markersRef.current.get(id);
if (marker && mapRef.current) {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
try {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 });
} catch {
/* map not yet initialized */
}
}
}, [])
}, []);
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
const invalidateSize = useCallback(() => {
try {
mapRef.current?.invalidateSize();
} catch {
/* map not yet initialized */
}
}, []);
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), []);
useEffect(() => {
if (!containerRef.current) return
if (!containerRef.current) return;
if (mapRef.current) {
mapRef.current.remove()
mapRef.current = null
mapRef.current.remove();
mapRef.current = null;
}
markersRef.current.clear()
markersRef.current.clear();
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: true,
scrollWheelZoom: false,
scrollWheelZoom: fullScreen ? true : false,
dragging: true,
touchZoom: true,
})
mapRef.current = map
});
mapRef.current = map;
const defaultTile = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
L.tileLayer(mapTileUrl || defaultTile, {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
// before loading tiles). On the journey mobile combined view we flyTo
// constantly when switching cards, so tiles lag visibly — force eager
// updates and keep a larger ring of off-screen tiles ready.
updateWhenIdle: false,
keepBuffer: 4,
} as any).addTo(map);
const items = buildMarkerItems(entries)
itemsRef.current = items
const items = buildMarkerItems(entries);
itemsRef.current = items;
const allCoords: L.LatLngTuple[] = []
const allCoords: L.LatLngTuple[] = [];
if (stableTrail.length > 1) {
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
const coords = stableTrail.map((p) => [p.lat, p.lng] as L.LatLngTuple);
L.polyline(coords, {
color: '#6366f1', weight: 3, opacity: 0.4,
dashArray: '6 4', lineCap: 'round',
}).addTo(map)
coords.forEach(c => allCoords.push(c))
color: '#6366f1',
weight: 3,
opacity: 0.4,
dashArray: '6 4',
lineCap: 'round',
}).addTo(map);
coords.forEach((c) => allCoords.push(c));
}
// route polyline — subtle dashed connection
if (items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
// route polyline — only in non-fullscreen (sidebar map) mode
if (!fullScreen && items.length > 1) {
const routeCoords = items.map((i) => [i.lat, i.lng] as L.LatLngTuple);
L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA',
weight: 1.5,
opacity: 0.5,
dashArray: '4 6',
lineCap: 'round', lineJoin: 'round',
}).addTo(map)
lineCap: 'round',
lineJoin: 'round',
}).addTo(map);
}
// place markers
items.forEach((item, i) => {
const pos: L.LatLngTuple = [item.lat, item.lng]
allCoords.push(pos)
const pos: L.LatLngTuple = [item.lat, item.lng];
allCoords.push(pos);
const icon = L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(i, false, !!dark),
})
html: markerSvg(item.dayColor, item.dayLabel, false),
});
const marker = L.marker(pos, { icon }).addTo(map)
const marker = L.marker(pos, { icon }).addTo(map);
marker.bindTooltip(item.label, {
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip',
})
});
marker.on('click', () => {
onMarkerClickRef.current?.(item.id)
})
onMarkerClickRef.current?.(item.id);
});
markersRef.current.set(item.id, marker)
})
markersRef.current.set(item.id, marker);
});
// fit bounds
requestAnimationFrame(() => {
if (!mapRef.current) return
if (!mapRef.current) return;
try {
map.invalidateSize()
map.invalidateSize();
if (allCoords.length > 0) {
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
const pb = paddingBottom || 50;
map.fitBounds(L.latLngBounds(allCoords), {
paddingTopLeft: [50, 50],
paddingBottomRight: [50, pb],
maxZoom: 16,
});
} else {
map.setView([30, 0], 2)
map.setView([30, 0], 2);
}
} catch {}
})
});
setTimeout(() => {
if (mapRef.current) map.invalidateSize()
}, 200)
if (mapRef.current) map.invalidateSize();
}, 200);
return () => {
map.remove()
mapRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, dark, mapTileUrl])
map.remove();
mapRef.current = null;
markersRef.current.clear();
};
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom]);
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
if (!activeMarkerId || !mapRef.current) return
if (!activeMarkerId || !mapRef.current) return;
// small delay to ensure markers are rendered after map build
const timer = setTimeout(() => {
highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId)
if (marker && mapRef.current) {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
highlightMarker(activeMarkerId);
const marker = markersRef.current.get(activeMarkerId);
if (!marker || !mapRef.current) return;
// fitBounds may still be pending when this fires — getZoom() throws
// "Set map center and zoom first" until the map has a view. Guard it.
try {
const currentZoom = mapRef.current.getZoom();
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 });
} catch {
mapRef.current.setView(marker.getLatLng(), 12);
}
}, 50)
return () => clearTimeout(timer)
}, [activeMarkerId])
}, 50);
return () => clearTimeout(timer);
}, [activeMarkerId]);
const zoomIn = () => mapRef.current?.zoomIn()
const zoomOut = () => mapRef.current?.zoomOut()
const zoomIn = () => mapRef.current?.zoomIn();
const zoomOut = () => mapRef.current?.zoomOut();
return (
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
<div
style={{
position: 'relative',
height: height === 9999 ? '100%' : height,
width: '100%',
borderRadius: 'inherit',
overflow: 'hidden',
}}
>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
<div
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
style={{
position: 'absolute',
bottom: 12,
right: 12,
zIndex: 400,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<button
onClick={zoomIn}
style={{
width: 32, height: 32, borderRadius: 8,
width: 32,
height: 32,
borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 16,
fontWeight: 700,
lineHeight: 1,
}}
>+</button>
>
+
</button>
<button
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
width: 32,
height: 32,
borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 16,
fontWeight: 700,
lineHeight: 1,
}}
></button>
>
</button>
</div>
</div>
)
})
);
});
export default JourneyMap
export default JourneyMap;
@@ -0,0 +1,61 @@
import { forwardRef, useImperativeHandle, useRef } from 'react';
import { useSettingsStore } from '../../store/settingsStore';
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL';
// Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle;
interface MapEntry {
id: string;
lat: number;
lng: number;
title?: string | null;
location_name?: string | null;
mood?: string | null;
entry_date: string;
dayColor?: string;
dayLabel?: number;
}
interface Props {
checkins: unknown[];
entries: MapEntry[];
trail?: { lat: number; lng: number }[];
height?: number;
dark?: boolean;
activeMarkerId?: string | null;
onMarkerClick?: (id: string, type?: string) => void;
fullScreen?: boolean;
paddingBottom?: number;
}
const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyMapAuto(props, ref) {
const provider = useSettingsStore((s) => s.settings.map_provider);
const token = useSettingsStore((s) => s.settings.mapbox_access_token);
const leafletRef = useRef<JourneyMapHandle>(null);
const glRef = useRef<JourneyMapGLHandle>(null);
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet — otherwise the map would just show a stub.
const useGL = provider === 'mapbox-gl' && !!token;
useImperativeHandle(
ref,
() => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
focusMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.focusMarker(id),
invalidateSize: () => (useGL ? glRef.current : leafletRef.current)?.invalidateSize(),
}),
[useGL]
);
if (useGL) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMapGL ref={glRef} {...(props as any)} />;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMap ref={leafletRef} {...(props as any)} />;
});
export default JourneyMapAuto;
@@ -0,0 +1,528 @@
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { useSettingsStore } from '../../store/settingsStore';
import {
addCustom3dBuildings,
addTerrainAndSky,
isStandardFamily,
supportsCustom3d,
wantsTerrain,
} from '../Map/mapboxSetup';
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void;
focusMarker: (id: string) => void;
invalidateSize: () => void;
}
interface MapEntry {
id: string;
lat: number;
lng: number;
title?: string | null;
location_name?: string | null;
mood?: string | null;
entry_date: string;
dayColor?: string;
dayLabel?: number;
}
interface Props {
checkins: unknown[];
entries: MapEntry[];
trail?: { lat: number; lng: number }[];
height?: number;
dark?: boolean;
activeMarkerId?: string | null;
onMarkerClick?: (id: string, type?: string) => void;
fullScreen?: boolean;
paddingBottom?: number;
}
interface Item {
id: string;
lat: number;
lng: number;
label: string;
locationName: string;
time: string;
dayColor: string;
dayLabel: number;
}
const MARKER_W = 28;
const MARKER_H = 36;
function buildItems(entries: MapEntry[]): Item[] {
const items: Item[] = [];
for (const e of entries) {
if (e.lat && e.lng) {
items.push({
id: e.id,
lat: e.lat,
lng: e.lng,
label: e.title || '',
locationName: e.location_name || '',
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
});
}
}
items.sort((a, b) => a.time.localeCompare(b.time));
return items;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatEntryDate(iso: string): string {
if (!iso) return '';
try {
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00');
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d);
} catch {
return iso;
}
}
// Inject the popup styles once per document. Two-line frosted-glass card in
// the Apple/Google Maps idiom — title on top, location / date subtly below.
function ensureJourneyPopupStyle() {
if (document.getElementById('trek-journey-popup-style')) return;
const s = document.createElement('style');
s.id = 'trek-journey-popup-style';
s.textContent = `
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
font-family: -apple-system, system-ui, sans-serif;
min-width: 160px;
max-width: 280px;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA;
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94);
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: #18181B;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub {
display: flex;
align-items: baseline;
gap: 7px;
margin-top: 3px;
font-size: 11.5px;
color: #71717A;
line-height: 1.35;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.trek-journey-popup-sep {
flex: 0 0 auto;
opacity: 0.55;
font-weight: 500;
}
.trek-journey-popup-date { flex: 0 0 auto; }
@keyframes trek-journey-popup-in {
from { opacity: 0; }
to { opacity: 1; }
}
`;
document.head.appendChild(s);
}
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
const fill = dayColor;
const textColor = '#fff';
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)';
const shadow = highlighted
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))';
const scale = highlighted ? 1.2 : 1;
const label = String(dayLabel);
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
// Anything animated (scale, filter) has to live on an inner child — otherwise
// the CSS transition would catch the map's per-frame translate updates and
// the marker smears all over the viewport while scrolling / flying.
const wrap = document.createElement('div');
wrap.style.cssText = `width:${MARKER_W}px;height:${MARKER_H}px;cursor:pointer;`;
const inner = document.createElement('div');
inner.className = 'trek-journey-marker-inner';
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`;
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>`;
wrap.appendChild(inner);
return wrap;
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = [];
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
ref
) {
const stableTrail = trail || EMPTY_TRAIL;
const mapboxStyle = useSettingsStore((s) => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard');
const mapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token || '');
const mapbox3d = useSettingsStore((s) => s.settings.mapbox_3d_enabled !== false);
const mapboxQuality = useSettingsStore((s) => s.settings.mapbox_quality_mode === true);
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<mapboxgl.Map | null>(null);
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map());
const itemsRef = useRef<Item[]>([]);
const highlightedRef = useRef<string | null>(null);
const popupRef = useRef<mapboxgl.Popup | null>(null);
const onMarkerClickRef = useRef(onMarkerClick);
onMarkerClickRef.current = onMarkerClick;
const darkRef = useRef(dark);
darkRef.current = dark;
const showPopup = useCallback((id: string) => {
const item = itemsRef.current.find((i) => i.id === id);
if (!item || !mapRef.current) return;
ensureJourneyPopupStyle();
// Primary line: user-given title. If none, fall back to the location
// name so we always show *something* useful on the top line.
const primaryRaw = item.label || item.locationName || 'Entry';
const secondaryPlace = item.label ? item.locationName : '';
const dateStr = formatEntryDate(item.time);
const primary = escapeHtml(primaryRaw);
const place = escapeHtml(secondaryPlace);
const date = escapeHtml(dateStr);
const subParts: string[] = [];
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`);
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`);
const subline =
subParts.length === 2
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
: subParts.join('');
const html = `
<div class="trek-journey-popup-title">${primary}</div>
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
`;
// Marker is bottom-anchored with a visible height of 36px (1.2× on
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
const offset: [number, number] = [0, -46];
if (popupRef.current) {
popupRef.current.setLngLat([item.lng, item.lat]);
popupRef.current.setHTML(html);
popupRef.current.setOffset(offset);
const el = popupRef.current.getElement();
if (el) el.classList.toggle('trek-dark', !!darkRef.current);
} else {
popupRef.current = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
closeOnMove: false,
anchor: 'bottom',
offset,
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
maxWidth: '280px',
})
.setLngLat([item.lng, item.lat])
.setHTML(html)
.addTo(mapRef.current);
}
}, []);
const hidePopup = useCallback(() => {
if (popupRef.current) {
try {
popupRef.current.remove();
} catch {
/* noop */
}
popupRef.current = null;
}
}, []);
const setMarkerStyle = useCallback((id: string, highlighted: boolean) => {
const item = itemsRef.current.find((i) => i.id === id);
const marker = markersRef.current.get(id);
if (!item || !marker) return;
const el = marker.getElement();
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null;
if (!currentInner) return;
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(item.dayColor, item.dayLabel, highlighted);
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement;
currentInner.style.cssText = nextInner.style.cssText;
currentInner.innerHTML = nextInner.innerHTML;
el.style.zIndex = highlighted ? '1000' : '0';
}, []);
const highlightMarker = useCallback(
(id: string | null) => {
const prev = highlightedRef.current;
highlightedRef.current = id;
if (prev && prev !== id) setMarkerStyle(prev, false);
if (id) {
setMarkerStyle(id, true);
showPopup(id);
} else {
hidePopup();
}
},
[setMarkerStyle, showPopup, hidePopup]
);
const focusMarker = useCallback(
(id: string) => {
highlightMarker(id);
const marker = markersRef.current.get(id);
if (!marker || !mapRef.current) return;
try {
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 14),
pitch: mapbox3d ? 45 : 0,
duration: 600,
});
} catch {
/* map not yet ready */
}
},
[highlightMarker, mapbox3d]
);
const invalidateSize = useCallback(() => {
try {
mapRef.current?.resize();
} catch {
/* map not yet ready */
}
}, []);
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [
highlightMarker,
focusMarker,
invalidateSize,
]);
// Build map once per style/token change. Markers and layers are rebuilt
// inside the same effect so they stay in sync with the active style.
useEffect(() => {
if (!containerRef.current || !mapboxToken) return;
mapboxgl.accessToken = mapboxToken;
const items = buildItems(entries);
itemsRef.current = items;
const bounds = new mapboxgl.LngLatBounds();
items.forEach((i) => bounds.extend([i.lng, i.lat]));
stableTrail.forEach((p) => bounds.extend([p.lng, p.lat]));
const hasPoints = items.length > 0 || stableTrail.length > 0;
const map = new mapboxgl.Map({
container: containerRef.current,
style: mapboxStyle,
center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1,
pitch: mapbox3d && fullScreen ? 45 : 0,
attributionControl: true,
antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator',
});
mapRef.current = map;
map.on('load', () => {
if (mapbox3d) {
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map);
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current);
}
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
// stay pinned to their coordinates at every zoom and pitch.
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try {
map.setTerrain(null);
} catch {
/* noop */
}
}
// route trail — dashed line connecting entries in time order
if (items.length > 1) {
const coords = items.map((i) => [i.lng, i.lat]);
if (map.getSource('journey-route'))
(map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
});
else {
map.addSource('journey-route', {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
},
});
map.addLayer({
id: 'journey-route-line',
type: 'line',
source: 'journey-route',
paint: {
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
'line-width': 1.5,
'line-opacity': 0.5,
'line-dasharray': [2, 3],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
});
}
}
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false);
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
onMarkerClickRef.current?.(item.id);
});
markersRef.current.set(item.id, marker);
});
// fit bounds to all points
if (hasPoints) {
const pb = paddingBottom || 50;
try {
map.fitBounds(bounds, {
padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16,
pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 0,
});
} catch {
/* empty bounds */
}
}
});
return () => {
markersRef.current.forEach((m) => m.remove());
markersRef.current.clear();
if (popupRef.current) {
try {
popupRef.current.remove();
} catch {
/* noop */
}
popupRef.current = null;
}
highlightedRef.current = null;
try {
map.remove();
} catch {
/* noop */
}
mapRef.current = null;
};
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom]);
// external activeMarkerId → highlight + flyTo
useEffect(() => {
if (!activeMarkerId || !mapRef.current) return;
const t = setTimeout(() => {
highlightMarker(activeMarkerId);
const marker = markersRef.current.get(activeMarkerId);
if (!marker || !mapRef.current) return;
try {
mapRef.current.flyTo({
center: marker.getLngLat(),
zoom: Math.max(mapRef.current.getZoom(), 12),
pitch: mapbox3d && fullScreen ? 45 : 0,
duration: 500,
});
} catch {
/* map not ready */
}
}, 50);
return () => clearTimeout(t);
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen]);
if (!mapboxToken) {
return (
<div
style={{
position: 'relative',
height: height === 9999 ? '100%' : height,
width: '100%',
borderRadius: 'inherit',
overflow: 'hidden',
}}
className="flex items-center justify-center bg-zinc-100 px-6 text-center dark:bg-zinc-800"
>
<div className="text-sm text-zinc-500">
No Mapbox access token configured.
<br />
<span className="text-xs">Settings Map Mapbox GL</span>
</div>
</div>
);
}
return (
<div
style={{
position: 'relative',
height: height === 9999 ? '100%' : height,
width: '100%',
borderRadius: 'inherit',
overflow: 'hidden',
}}
>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
</div>
);
});
export default JourneyMapGL;
@@ -1,9 +1,9 @@
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import MarkdownToolbar from './MarkdownToolbar';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import MarkdownToolbar from './MarkdownToolbar';
function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
const textarea = document.createElement('textarea');
@@ -1,12 +1,15 @@
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
import { Bold, Heading2, Italic, Link, List, ListOrdered, Minus, Quote } from 'lucide-react';
interface Props {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
onUpdate: (value: string) => void
dark?: boolean
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
onUpdate: (value: string) => void;
dark?: boolean;
}
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string }
type FormatAction =
| { type: 'wrap'; before: string; after: string }
| { type: 'line'; prefix: string }
| { type: 'insert'; text: string };
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
@@ -17,68 +20,80 @@ const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }>
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
{ icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } },
]
];
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
const apply = (action: FormatAction) => {
const ta = textareaRef.current
if (!ta) return
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart
const end = ta.selectionEnd
const text = ta.value
const selected = text.slice(start, end)
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = ta.value;
const selected = text.slice(start, end);
let result: string
let cursorPos: number
let result: string;
let cursorPos: number;
if (action.type === 'wrap') {
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end);
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length;
} else if (action.type === 'insert') {
result = text.slice(0, start) + action.text + text.slice(end)
cursorPos = start + action.text.length
result = text.slice(0, start) + action.text + text.slice(end);
cursorPos = start + action.text.length;
} else {
// line prefix — find start of current line
const lineStart = text.lastIndexOf('\n', start - 1) + 1
result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart)
cursorPos = start + action.prefix.length
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart);
cursorPos = start + action.prefix.length;
}
onUpdate(result)
onUpdate(result);
// restore cursor after React re-render
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(cursorPos, cursorPos)
})
}
ta.focus();
ta.setSelectionRange(cursorPos, cursorPos);
});
};
return (
<div style={{
display: 'flex', gap: 2, padding: '6px 4px',
borderBottom: `1px solid var(--journal-border)`,
overflowX: 'auto',
}}>
{ACTIONS.map(a => (
<div
style={{
display: 'flex',
gap: 2,
padding: '6px 4px',
borderBottom: `1px solid var(--journal-border)`,
overflowX: 'auto',
}}
>
{ACTIONS.map((a) => (
<button
key={a.label}
type="button"
title={a.label}
onClick={() => apply(a.action)}
style={{
width: 32, height: 32, borderRadius: 6,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none',
color: 'var(--journal-muted)', cursor: 'pointer',
width: 32,
height: 32,
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
color: 'var(--journal-muted)',
cursor: 'pointer',
flexShrink: 0,
}}
onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onMouseEnter={(e) =>
(e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)')
}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
<a.icon size={15} />
</button>
))}
</div>
)
);
}
@@ -0,0 +1,190 @@
import {
Camera,
Cloud,
CloudLightning,
CloudRain,
CloudSun,
Frown,
Laugh,
MapPin,
Meh,
Smile,
Snowflake,
Sun,
} from 'lucide-react';
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore';
import { formatLocationName } from '../../utils/formatters';
const MOOD_ICONS: Record<string, typeof Smile> = {
amazing: Laugh,
good: Smile,
neutral: Meh,
rough: Frown,
};
const MOOD_COLORS: Record<string, string> = {
amazing: 'text-pink-500',
good: 'text-amber-500',
neutral: 'text-zinc-400',
rough: 'text-violet-500',
};
const WEATHER_ICONS: Record<string, typeof Sun> = {
sunny: Sun,
partly: CloudSun,
cloudy: Cloud,
rainy: CloudRain,
stormy: CloudLightning,
cold: Snowflake,
};
function photoUrl(p: JourneyPhoto): string {
return `/api/photos/${p.photo_id}/thumbnail`;
}
function stripMarkdown(text: string): string {
return text
.replace(/[#*_~`>\[\]()!|-]/g, '')
.replace(/\n+/g, ' ')
.trim();
}
interface Props {
entry:
| JourneyEntry
| {
id: number;
type: string;
title?: string | null;
location_name?: string | null;
location_lat?: number | null;
location_lng?: number | null;
entry_date: string;
entry_time?: string | null;
mood?: string | null;
weather?: string | null;
photos?: { photo_id: number }[];
story?: string | null;
};
dayLabel: number;
dayColor: string;
isActive: boolean;
onClick: () => void;
publicPhotoUrl?: (photoId: number) => string;
}
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng);
const hasPhotos = entry.photos && entry.photos.length > 0;
const firstPhoto = hasPhotos ? entry.photos![0] : null;
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null;
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : '';
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null;
const thumbSrc = firstPhoto
? publicPhotoUrl
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
: photoUrl(firstPhoto as JourneyPhoto)
: null;
const date = new Date(entry.entry_date + 'T00:00:00');
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
const storyPreview = entry.story ? stripMarkdown(entry.story) : '';
return (
<button
onClick={onClick}
className={`flex-shrink-0 overflow-hidden rounded-xl text-left transition-all duration-100 ${
isActive
? 'w-[320px] bg-white shadow-lg ring-2 ring-zinc-900/70 dark:bg-zinc-800 dark:ring-white/60 sm:w-[340px]'
: 'w-[240px] bg-white/90 shadow-md dark:bg-zinc-800/90 sm:w-[260px]'
} backdrop-blur-lg`}
>
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
{/* Photo thumbnail */}
{thumbSrc ? (
<div
className={`${isActive ? 'w-[110px]' : 'w-[90px]'} relative flex-shrink-0 overflow-hidden transition-all duration-100`}
>
<img src={thumbSrc} alt="" className="h-full w-full object-cover" loading="lazy" />
{hasPhotos && entry.photos!.length > 1 && (
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 rounded bg-black/60 px-1 py-0.5 text-[10px] font-medium text-white">
<Camera size={10} />
{entry.photos!.length}
</div>
)}
</div>
) : (
<div
className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex flex-shrink-0 items-center justify-center bg-zinc-100 transition-all duration-100 dark:bg-zinc-700`}
>
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
</div>
)}
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col p-3">
{/* Day number + date + mood/weather */}
<div className="mb-1 flex items-center gap-1.5">
<span
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[10px] font-bold text-white"
style={{ background: dayColor }}
>
{dayLabel}
</span>
<span className="text-[11px] font-medium text-zinc-400">{dateStr}</span>
{entry.entry_time && <span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>}
<div className="ml-auto flex flex-shrink-0 items-center gap-1.5">
{MoodIcon && (
<span
className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${
entry.mood === 'amazing'
? 'bg-pink-100 dark:bg-pink-900/30'
: entry.mood === 'good'
? 'bg-amber-100 dark:bg-amber-900/30'
: entry.mood === 'rough'
? 'bg-violet-100 dark:bg-violet-900/30'
: 'bg-zinc-100 dark:bg-zinc-700'
}`}
>
<MoodIcon size={11} className={moodColor} />
</span>
)}
{WeatherIcon && (
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-700">
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
</span>
)}
</div>
</div>
{/* Title */}
<h4 className="truncate text-[13px] font-semibold leading-tight text-zinc-900 dark:text-white">
{entry.title ||
(entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
</h4>
{/* Story preview (1-2 lines, only on active card) */}
{isActive && storyPreview && (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-zinc-400 dark:text-zinc-500">
{storyPreview}
</p>
)}
{/* Location badge */}
<div className="mt-auto flex items-center gap-1">
{hasLocation ? (
<span className="inline-flex max-w-full items-center gap-1 overflow-hidden rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
</span>
) : (
<span className="text-[10px] italic text-zinc-400">No location</span>
)}
</div>
</div>
</div>
</button>
);
}
@@ -0,0 +1,281 @@
import {
Camera,
Clock,
Cloud,
CloudLightning,
CloudRain,
CloudSun,
Frown,
Laugh,
MapPin,
Meh,
Pencil,
Smile,
Snowflake,
Sun,
ThumbsDown,
ThumbsUp,
Trash2,
X,
} from 'lucide-react';
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore';
import { formatLocationName } from '../../utils/formatters';
import JournalBody from './JournalBody';
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: {
icon: Laugh,
label: 'Amazing',
bg: 'bg-pink-50 dark:bg-pink-900/20',
text: 'text-pink-600 dark:text-pink-400',
},
good: {
icon: Smile,
label: 'Good',
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-600 dark:text-amber-400',
},
neutral: {
icon: Meh,
label: 'Neutral',
bg: 'bg-zinc-100 dark:bg-zinc-800',
text: 'text-zinc-500 dark:text-zinc-400',
},
rough: {
icon: Frown,
label: 'Rough',
bg: 'bg-violet-50 dark:bg-violet-900/20',
text: 'text-violet-600 dark:text-violet-400',
},
};
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'Sunny' },
partly: { icon: CloudSun, label: 'Partly cloudy' },
cloudy: { icon: Cloud, label: 'Cloudy' },
rainy: { icon: CloudRain, label: 'Rainy' },
stormy: { icon: CloudLightning, label: 'Stormy' },
cold: { icon: Snowflake, label: 'Cold' },
};
function photoUrl(
p: JourneyPhoto,
size: 'thumbnail' | 'original' = 'original',
builder?: (id: number) => string
): string {
if (builder) return builder(p.photo_id);
return `/api/photos/${p.photo_id}/${size}`;
}
interface Props {
entry: JourneyEntry;
readOnly?: boolean;
publicPhotoUrl?: (photoId: number) => string;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
onPhotoClick: (photos: JourneyPhoto[], index: number) => void;
}
export default function MobileEntryView({
entry,
readOnly,
publicPhotoUrl,
onClose,
onEdit,
onDelete,
onPhotoClick,
}: Props) {
const photos = entry.photos || [];
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null;
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null;
const prosArr = entry.pros_cons?.pros ?? [];
const consArr = entry.pros_cons?.cons ?? [];
const hasProscons = prosArr.length > 0 || consArr.length > 0;
const date = new Date(entry.entry_date + 'T00:00:00');
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' });
return (
<div
className="fixed inset-0 z-[9999] flex flex-col overflow-hidden bg-white dark:bg-zinc-950"
style={{ height: '100dvh' }}
>
{/* Top bar */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<button
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
<X size={20} />
</button>
{!readOnly && (
<div className="flex items-center gap-1.5">
<button
onClick={() => {
onClose();
onEdit();
}}
className="flex h-8 items-center gap-1.5 rounded-lg bg-zinc-100 px-3 text-[12px] font-medium text-zinc-700 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
<Pencil size={13} />
Edit
</button>
<button
onClick={() => {
onClose();
onDelete();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Trash2 size={15} />
</button>
</div>
)}
</div>
{/* Scrollable content */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
{/* Hero photo(s) */}
{photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
alt=""
className="max-h-[50vh] w-full cursor-pointer object-cover"
onClick={() => onPhotoClick(photos, 0)}
/>
{photos.length > 1 && (
<div className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-black/60 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
<Camera size={12} />
{photos.length} photos
</div>
)}
{/* Photo strip for multiple photos */}
{photos.length > 1 && (
<div className="flex gap-1 overflow-x-auto bg-zinc-50 px-4 py-2 dark:bg-zinc-900">
{photos.map((p, i) => (
<img
key={p.id || i}
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
alt=""
className="h-16 w-16 flex-shrink-0 cursor-pointer rounded-lg object-cover ring-zinc-900/30 transition-all hover:ring-2 dark:ring-white/30"
onClick={() => onPhotoClick(photos, i)}
/>
))}
</div>
)}
</div>
)}
{/* Content */}
<div className="px-5 py-5 pb-32">
{/* Date + time + location header */}
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
{entry.entry_time && (
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</span>
)}
</div>
{entry.location_name && (
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[12px] font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
<MapPin size={12} className="flex-shrink-0 text-zinc-500 dark:text-zinc-400" />
{formatLocationName(entry.location_name)}
</span>
</div>
)}
{/* Title */}
{entry.title && (
<h1 className="mb-4 text-[22px] font-bold leading-tight tracking-tight text-zinc-900 dark:text-white">
{entry.title}
</h1>
)}
{/* Mood + Weather chips */}
{(mood || weather) && (
<div className="mb-4 flex items-center gap-2">
{mood && (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold ${mood.bg} ${mood.text}`}
>
<mood.icon size={13} />
{mood.label}
</span>
)}
{weather && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-semibold text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
<weather.icon size={13} />
{weather.label}
</span>
)}
</div>
)}
{/* Story */}
{entry.story && (
<div className="mb-5 text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300">
<JournalBody text={entry.story} />
</div>
)}
{/* Tags */}
{entry.tags && entry.tags.length > 0 && (
<div className="mb-5 flex flex-wrap gap-1.5">
{entry.tags.map((tag, i) => (
<span
key={i}
className="rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400"
>
{tag}
</span>
))}
</div>
)}
{/* Pros & Cons */}
{hasProscons && (
<div className="mb-5 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700">
{prosArr.length > 0 && (
<div className="px-4 py-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
<ThumbsUp size={12} /> Pros
</div>
<ul className="space-y-1">
{prosArr.map((p, i) => (
<li key={i} className="flex items-start gap-2 text-[13px] text-zinc-700 dark:text-zinc-300">
<span className="mt-0.5 text-emerald-500">+</span> {p}
</li>
))}
</ul>
</div>
)}
{prosArr.length > 0 && consArr.length > 0 && (
<div className="border-t border-zinc-200 dark:border-zinc-700" />
)}
{consArr.length > 0 && (
<div className="px-4 py-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-red-500 dark:text-red-400">
<ThumbsDown size={12} /> Cons
</div>
<ul className="space-y-1">
{consArr.map((c, i) => (
<li key={i} className="flex items-start gap-2 text-[13px] text-zinc-700 dark:text-zinc-300">
<span className="mt-0.5 text-red-500"></span> {c}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,255 @@
import { Plus } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { JourneyEntry } from '../../store/journeyStore';
import { DAY_COLORS } from './dayColors';
import type { JourneyMapHandle } from './JourneyMap';
import JourneyMap from './JourneyMap';
import MobileEntryCard from './MobileEntryCard';
interface MapEntry {
id: string;
lat: number;
lng: number;
title?: string | null;
mood?: string | null;
entry_date: string;
}
interface Props {
entries: JourneyEntry[] | any[];
mapEntries: MapEntry[];
trail?: { lat: number; lng: number }[];
dark?: boolean;
readOnly?: boolean;
onEntryClick: (entry: any) => void;
onAddEntry?: () => void;
publicPhotoUrl?: (photoId: number) => string;
carouselBottom?: string;
}
export default function MobileMapTimeline({
entries,
mapEntries,
trail,
dark,
readOnly,
onEntryClick,
onAddEntry,
publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null);
const carouselRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())];
const counters = new Map<string, number>();
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date);
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1;
counters.set(e.entry_date, dayLabel);
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] };
});
}, [entries]);
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
// Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback(
(index: number) => {
const entry = entries[index];
if (!entry) return;
const mapEntry = mapEntries.find((m) => String(m.id) === String(entry.id));
if (mapEntry) {
try {
mapRef.current?.focusMarker(String(mapEntry.id));
} catch {}
} else {
try {
mapRef.current?.highlightMarker(null);
} catch {}
}
},
[entries, mapEntries]
);
// Pick the card that's currently closest to the carousel horizontal center.
// More stable than IntersectionObserver thresholds when the active card can
// drift toward the viewport edge with proximity snapping.
const pickNearestCard = useCallback(() => {
const el = carouselRef.current;
if (!el) return;
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
cardRefs.current.forEach((node, idx) => {
const r = node.getBoundingClientRect();
const cardCenter = r.left + r.width / 2;
const d = Math.abs(cardCenter - containerCenter);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
});
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCarousel(bestIdx);
return bestIdx;
});
}, [syncMapToCarousel]);
// Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
useEffect(() => {
const el = carouselRef.current;
if (!el || entries.length === 0) return;
let settleTimer: number | null = null;
const onScroll = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, 150);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (settleTimer != null) window.clearTimeout(settleTimer);
};
}, [entries.length, pickNearestCard]);
// Scroll a given card into the horizontal center of the carousel
const scrollCardIntoCenter = useCallback((idx: number) => {
const card = cardRefs.current.get(idx);
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}, []);
// Scroll carousel to entry when map marker is clicked
const handleMarkerClick = useCallback(
(id: string) => {
const idx = entries.findIndex((e: any) => String(e.id) === id);
if (idx === -1) return;
setActiveIndex(idx);
scrollCardIntoCenter(idx);
},
[entries, scrollCardIntoCenter]
);
// Tap on a card: if it's already active, open the edit view; otherwise
// activate + center it first (don't jump straight into the editor).
const handleCardTap = useCallback(
(entry: any, idx: number) => {
if (idx === activeIndex) {
onEntryClick(entry);
} else {
setActiveIndex(idx);
scrollCardIntoCenter(idx);
}
},
[activeIndex, onEntryClick, scrollCardIntoCenter]
);
// Initial map focus — delay to let Leaflet initialize and fitBounds
useEffect(() => {
if (entries.length > 0) {
const timer = setTimeout(() => syncMapToCarousel(0), 500);
return () => clearTimeout(timer);
}
}, [entries.length]);
const activeEntryId = entries[activeIndex] ? String(entries[activeIndex].id) : null;
if (entries.length === 0) {
return (
<div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
height={9999}
dark={dark}
onMarkerClick={handleMarkerClick}
fullScreen
/>
{!readOnly && onAddEntry && (
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
<button
onClick={onAddEntry}
className="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg transition-transform hover:scale-105 active:scale-95 dark:bg-white dark:text-zinc-900"
>
<Plus size={20} />
</button>
</div>
)}
</div>
);
}
return (
<div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */}
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
height={9999}
dark={dark}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={200}
/>
{/* Bottom carousel */}
<div className="fixed left-0 right-0 z-40" style={{ touchAction: 'pan-x', bottom: carouselBottom }}>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{entries.map((entry: any, i: number) => (
<div
key={entry.id}
data-idx={i}
ref={(node) => {
if (node) cardRefs.current.set(i, node);
else cardRefs.current.delete(i);
}}
style={{ scrollSnapAlign: 'center' }}
>
<MobileEntryCard
entry={entry}
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl}
/>
</div>
))}
</div>
</div>
{/* FAB: add entry — bottom right, above the timeline carousel */}
{!readOnly && onAddEntry && (
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}>
<button
onClick={onAddEntry}
className="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg transition-transform hover:scale-105 active:scale-95 dark:bg-white dark:text-zinc-900"
>
<Plus size={20} />
</button>
</div>
)}
</div>
);
}
@@ -10,7 +10,7 @@ vi.mock('../../api/websocket', () => ({
removeListener: vi.fn(),
}));
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import PhotoLightbox from './PhotoLightbox';
+156 -75
View File
@@ -1,74 +1,83 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface LightboxPhoto {
id: string
src: string
caption?: string | null
provider?: string
asset_id?: string | null
owner_id?: number | null
id: string;
src: string;
caption?: string | null;
provider?: string;
asset_id?: string | null;
owner_id?: number | null;
}
interface Props {
photos: LightboxPhoto[]
startIndex?: number
onClose: () => void
photos: LightboxPhoto[];
startIndex?: number;
onClose: () => void;
}
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
const [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null)
const [idx, setIdx] = useState(startIndex);
const touchStart = useRef<{ x: number; y: number } | null>(null);
const photo = photos[idx]
const hasPrev = idx > 0
const hasNext = idx < photos.length - 1
const photo = photos[idx];
const hasPrev = idx > 0;
const hasNext = idx < photos.length - 1;
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
const prev = useCallback(() => {
if (hasPrev) setIdx((i) => i - 1);
}, [hasPrev]);
const next = useCallback(() => {
if (hasNext) setIdx((i) => i + 1);
}, [hasNext]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [prev, next, onClose])
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [prev, next, onClose]);
const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0]
touchStart.current = { x: t.clientX, y: t.clientY }
}
const t = e.touches[0];
touchStart.current = { x: t.clientX, y: t.clientY };
};
const onTouchEnd = (e: React.TouchEvent) => {
if (!touchStart.current) return
const t = e.changedTouches[0]
const dx = t.clientX - touchStart.current.x
const dy = t.clientY - touchStart.current.y
if (!touchStart.current) return;
const t = e.changedTouches[0];
const dx = t.clientX - touchStart.current.x;
const dy = t.clientY - touchStart.current.y;
// swipe down to close
if (dy > 80 && Math.abs(dx) < 60) {
onClose()
return
onClose();
return;
}
// horizontal swipe
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0) next()
else prev()
if (dx < 0) next();
else prev();
}
touchStart.current = null
}
touchStart.current = null;
};
if (!photo) return null
if (!photo) return null;
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
position: 'fixed',
inset: 0,
zIndex: 500,
background: 'rgba(0,0,0,0.92)',
backdropFilter: 'blur(20px)',
display: 'flex',
flexDirection: 'column',
paddingBottom: 'var(--bottom-nav-h)',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
@@ -76,32 +85,72 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
{/* Photo area — centered with nav overlays */}
<div
className="group/lightbox"
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Top bar */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 20px',
}}
>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<button
onClick={onClose}
style={{
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '50%',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
cursor: 'pointer',
}}
>
<X size={18} />
</button>
</div>
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<button
onClick={prev}
className="flex transition-opacity sm:opacity-0 sm:group-hover/lightbox:opacity-100"
style={{
position: 'absolute',
left: 16,
zIndex: 5,
width: 44,
height: 44,
borderRadius: '50%',
background: 'rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
cursor: 'pointer',
}}
>
<ChevronLeft size={22} />
</button>
)}
@@ -112,38 +161,70 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
maxWidth: '92vw',
maxHeight: '92vh',
objectFit: 'contain',
borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
{/* Next button */}
{hasNext && (
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<button
onClick={next}
className="flex transition-opacity sm:opacity-0 sm:group-hover/lightbox:opacity-100"
style={{
position: 'absolute',
right: 16,
zIndex: 5,
width: 44,
height: 44,
borderRadius: '50%',
background: 'rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
cursor: 'pointer',
}}
>
<ChevronRight size={22} />
</button>
)}
{/* Caption — bottom center overlay */}
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{
fontSize: 14, fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
<div
style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 5,
maxWidth: '70%',
textAlign: 'center',
}}
>
<p
style={{
fontSize: 14,
fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)',
margin: 0,
lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)',
backdropFilter: 'blur(8px)',
padding: '6px 14px',
borderRadius: 10,
}}
>
{photo.caption}
</p>
</div>
)}
</div>
</div>
)
);
}
@@ -0,0 +1,32 @@
export const DAY_COLORS = [
'#6366f1', // indigo
'#f97316', // orange
'#14b8a6', // teal
'#ec4899', // pink
'#22c55e', // green
'#3b82f6', // blue
'#a855f7', // purple
'#ef4444', // red
'#f59e0b', // amber
'#06b6d4', // cyan
'#84cc16', // lime
'#f43f5e', // rose
'#8b5cf6', // violet
'#10b981', // emerald
'#fb923c', // orange-400
'#60a5fa', // blue-400
'#c084fc', // purple-400
'#34d399', // emerald-400
'#fbbf24', // amber-400
'#e879f9', // fuchsia
'#4ade80', // green-400
'#f87171', // red-400
'#38bdf8', // sky-400
'#a3e635', // lime-400
'#fb7185', // rose-400
'#818cf8', // indigo-400
'#2dd4bf', // teal-400
'#facc15', // yellow
'#c026d3', // fuchsia-600
'#0ea5e9', // sky-500
]
@@ -16,11 +16,13 @@ vi.mock('react-router-dom', async () => {
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 { buildSettings, buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
@@ -39,7 +41,7 @@ describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />);
expect(screen.getByText('Trips')).toBeInTheDocument();
expect(screen.getByText('My Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
@@ -99,4 +101,39 @@ describe('BottomNav', () => {
// 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', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('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', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
seedStore(useAddonStore, {
addons: [
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
],
});
render(<BottomNav />);
expect(screen.getByText('Vacances')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
seedStore(useAddonStore, {
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
});
render(<BottomNav />);
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
});
});
+63 -62
View File
@@ -1,40 +1,41 @@
import { useState } from 'react'
import { NavLink, useNavigate } 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 type { LucideIcon } from 'lucide-react'
import type { LucideIcon } from 'lucide-react';
import { CalendarDays, Compass, Globe, LogOut, Plane, Settings, Shield, User } from 'lucide-react';
import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from '../../i18n';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
{ to: '/trips', label: 'Trips', icon: Plane },
]
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
journey: { to: '/journey', label: 'Journey', icon: Compass },
}
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
};
export default function BottomNav() {
const { t } = useTranslation()
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 { t } = useTranslation();
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 items = [...BASE_ITEMS]
for (const addon of globalAddons) {
const nav = ADDON_NAV[addon.id]
if (nav) items.push(nav)
}
const items: { to: string; label: string; icon: LucideIcon }[] = [
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
...globalAddons.flatMap((addon) => {
const nav = ADDON_NAV[addon.id];
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : [];
}),
];
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"
className="sticky bottom-0 z-50 mt-auto flex flex-shrink-0 items-start justify-around border-t border-zinc-200 pt-3 dark:border-zinc-800 md:hidden"
style={{
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
@@ -48,7 +49,7 @@ export default function BottomNav() {
key={to}
to={to}
className={({ isActive }) =>
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
`flex min-w-[60px] flex-col items-center gap-1 px-3 py-1 ${
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
}`
}
@@ -59,33 +60,33 @@ export default function BottomNav() {
))}
<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"
className="flex min-w-[60px] flex-col items-center gap-1 px-3 py-1 text-zinc-400 dark:text-zinc-500"
>
<User size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
<span className="text-[10px] font-medium">{t('nav.profile')}</span>
</button>
</nav>
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
</>
)
);
}
function ProfileSheet({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const { t } = useTranslation();
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const handleNav = (path: string) => {
onClose()
navigate(path)
}
onClose();
navigate(path);
};
const handleLogout = () => {
onClose()
logout()
navigate('/login')
}
onClose();
logout();
navigate('/login');
};
return (
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
@@ -94,71 +95,71 @@ function ProfileSheet({ onClose }: { onClose: () => void }) {
{/* Sheet */}
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
className="absolute bottom-0 left-0 right-0 overflow-hidden rounded-t-2xl bg-white dark:bg-zinc-900"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
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 className="flex justify-center pb-2 pt-3">
<div className="h-1 w-10 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">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-zinc-900 text-[16px] font-bold text-white dark:bg-white dark:text-zinc-900">
{(user?.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<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>
<p className="truncate text-[12px] text-zinc-500">{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">
<span className="flex items-center gap-1 rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
<Shield size={10} /> Admin
</span>
)}
</div>
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
<div className="mx-4 h-px bg-zinc-100 dark:bg-zinc-800" />
{/* Links */}
<div className="py-2 px-2">
<div className="px-2 py-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"
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 dark:hover:bg-zinc-800 dark:active:bg-zinc-800"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
<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"
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 dark:hover:bg-zinc-800 dark:active:bg-zinc-800"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
<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="mx-4 h-px bg-zinc-100 dark:bg-zinc-800" />
{/* Logout */}
<div className="py-2 px-2">
<div className="px-2 py-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"
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition-colors hover:bg-red-50 active:bg-red-100 dark:hover:bg-red-900/20"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
<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>
)
);
}
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import DemoBanner from './DemoBanner';
+257 -89
View File
@@ -1,24 +1,40 @@
import React, { useState, useEffect } from 'react'
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
import { useTranslation } from '../../i18n'
import {
ArrowRightLeft,
CalendarDays,
Clock,
Database,
FileText,
Github,
Globe,
Key,
ListChecks,
Map,
Puzzle,
Shield,
Upload,
Users,
Wallet,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
interface DemoTexts {
titleBefore: string
titleAfter: string
title: string
description: string
resetIn: string
minutes: string
uploadNote: string
fullVersionTitle: string
features: string[]
addonsTitle: string
addons: [string, string][]
whatIs: string
whatIsDesc: string
selfHost: string
selfHostLink: string
close: string
titleBefore: string;
titleAfter: string;
title: string;
description: string;
resetIn: string;
minutes: string;
uploadNote: string;
fullVersionTitle: string;
features: string[];
addonsTitle: string;
addons: [string, string][];
whatIs: string;
whatIsDesc: string;
selfHost: string;
selfHostLink: string;
close: string;
}
const texts: Record<string, DemoTexts> = {
@@ -26,7 +42,8 @@ const texts: Record<string, DemoTexts> = {
titleBefore: 'Willkommen bei ',
titleAfter: '',
title: 'Willkommen zur TREK Demo',
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
description:
'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
resetIn: 'Naechster Reset in',
minutes: 'Minuten',
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
@@ -49,7 +66,8 @@ const texts: Record<string, DemoTexts> = {
['Widgets', 'Waehrungsrechner & Zeitzonen'],
],
whatIs: 'Was ist TREK?',
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
whatIsDesc:
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten',
close: 'Verstanden',
@@ -81,7 +99,8 @@ const texts: Record<string, DemoTexts> = {
['Widgets', 'Currency converter & timezones'],
],
whatIs: 'What is TREK?',
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
whatIsDesc:
'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
selfHost: 'Open source — ',
selfHostLink: 'self-host it',
close: 'Got it',
@@ -113,7 +132,8 @@ const texts: Record<string, DemoTexts> = {
['Widgets', 'Conversor de divisas y zonas horarias'],
],
whatIs: '¿Qué es TREK?',
whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
whatIsDesc:
'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
selfHost: 'Código abierto — ',
selfHostLink: 'alójalo tú mismo',
close: 'Entendido',
@@ -214,79 +234,166 @@ const texts: Record<string, DemoTexts> = {
selfHostLink: 'استضفه بنفسك',
close: 'فهمت',
},
}
id: {
titleBefore: 'Selamat datang di ',
titleAfter: '',
title: 'Selamat datang di Demo TREK',
description:
'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
resetIn: 'Atur ulang berikutnya dalam',
minutes: 'menit',
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
fullVersionTitle: 'Selain itu dalam versi lengkap:',
features: [
'Unggah file (foto, dokumen, sampul)',
'Manajemen kunci API (Google Maps, Cuaca)',
'Manajemen pengguna & izin',
'Pencadangan otomatis',
'Manajemen Addon (aktifkan/nonaktifkan)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
addons: [
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
['Pengepakan', 'Daftar periksa per perjalanan'],
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
['Dokumen', 'Lampirkan file ke perjalanan'],
['Widget', 'Konverter mata uang & zona waktu'],
],
whatIs: 'Apa itu TREK?',
whatIsDesc:
'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
selfHost: 'Buka sumber — ',
selfHostLink: 'host mandiri',
close: 'Mengerti',
},
};
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield];
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft];
export default function DemoBanner(): React.ReactElement | null {
const [dismissed, setDismissed] = useState<boolean>(false)
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes())
const { language } = useTranslation()
const t = texts[language] || texts.en
const [dismissed, setDismissed] = useState<boolean>(false);
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes());
const { language } = useTranslation();
const t = texts[language] || texts.en;
useEffect(() => {
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000)
return () => clearInterval(interval)
}, [])
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
return () => clearInterval(interval);
}, []);
if (dismissed) return null
if (dismissed) return null;
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16, overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={() => setDismissed(true)}>
<div style={{
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: '90vh', overflow: 'auto',
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99999,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingTop: 'max(16px, env(safe-area-inset-top))',
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
paddingLeft: 16,
paddingRight: 16,
overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}}
onClick={() => setDismissed(true)}
>
<div
style={{
background: 'white',
borderRadius: 20,
padding: '28px 24px 0',
maxWidth: 480,
width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: 'min(90vh, calc(100dvh - 96px))',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
<h2
style={{
margin: 0,
fontSize: 17,
fontWeight: 700,
color: '#111827',
display: 'flex',
alignItems: 'center',
gap: 5,
}}
>
{t.titleBefore}
<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />
{t.titleAfter}
</h2>
</div>
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
{t.description}
</p>
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>{t.description}</p>
{/* Timer + Upload note */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
}}>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 6,
background: '#f0f9ff',
border: '1px solid #bae6fd',
borderRadius: 10,
padding: '8px 10px',
}}
>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes}
</span>
</div>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
}}>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 6,
background: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 10,
padding: '8px 10px',
}}
>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
</div>
</div>
{/* What is TREK */}
<div style={{
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
border: '1px solid #e2e8f0',
}}>
<div
style={{
background: '#f8fafc',
borderRadius: 12,
padding: '12px 14px',
marginBottom: 16,
border: '1px solid #e2e8f0',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} />
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
<span
style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}
>
{t.whatIs}
</span>
</div>
@@ -294,67 +401,128 @@ export default function DemoBanner(): React.ReactElement | null {
</div>
{/* Addons */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<p
style={{
fontSize: 10,
fontWeight: 700,
color: '#374151',
margin: '0 0 8px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Puzzle size={12} />
{t.addonsTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.addons.map(([name, desc], i) => {
const Icon = addonIcons[i]
const Icon = addonIcons[i];
return (
<div key={name} style={{
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
border: '1px solid #f1f5f9',
}}>
<div
key={name}
style={{
background: '#f8fafc',
borderRadius: 10,
padding: '8px 10px',
border: '1px solid #f1f5f9',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
</div>
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
</div>
)
);
})}
</div>
{/* Full version features */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<p
style={{
fontSize: 10,
fontWeight: 700,
color: '#374151',
margin: '0 0 8px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Shield size={12} />
{t.fullVersionTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.features.map((text, i) => {
const Icon = featureIcons[i]
const Icon = featureIcons[i];
return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
<div
key={text}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 11,
color: '#4b5563',
padding: '4px 0',
}}
>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span>
</div>
)
);
})}
</div>
{/* Footer */}
<div style={{
paddingTop: 14, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div
style={{
padding: '14px 0 20px',
borderTop: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'sticky',
bottom: 0,
background: 'white',
marginTop: 'auto',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} />
<span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
<a
href="https://github.com/mauriceboe/TREK"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
>
{t.selfHostLink}
</a>
</div>
<button onClick={() => setDismissed(true)} style={{
background: '#111827', color: 'white', border: 'none',
borderRadius: 10, padding: '8px 20px', fontSize: 12,
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
<button
onClick={() => setDismissed(true)}
style={{
background: '#111827',
color: 'white',
border: 'none',
borderRadius: 10,
padding: '8px 20px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{t.close}
</button>
</div>
</div>
</div>
)
);
}
@@ -1,11 +1,11 @@
// FE-COMP-BELL-001 to FE-COMP-BELL-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import InAppNotificationBell from './InAppNotificationBell';
let _notifId = 1;
@@ -69,7 +69,7 @@ describe('InAppNotificationBell', () => {
const { server } = await import('../../../tests/helpers/msw/server');
server.use(
http.get('/api/notifications/in-app', () => HttpResponse.json({ notifications: [], total: 0, unread_count: 0 })),
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 })),
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 }))
);
const user = userEvent.setup();
render(<InAppNotificationBell />);
@@ -93,11 +93,24 @@ describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => {
const user = userEvent.setup();
const notification = {
id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: 2,
sender_username: 'alice', sender_avatar: null, recipient_id: 1,
title_key: 'test', title_params: '{}', text_key: 'test.text', text_params: '{}',
positive_text_key: null, negative_text_key: null, response: null,
navigate_text_key: null, navigate_target: null, is_read: 0,
id: 1,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: 2,
sender_username: 'alice',
sender_avatar: null,
recipient_id: 1,
title_key: 'test',
title_params: '{}',
text_key: 'test.text',
text_params: '{}',
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: 0,
created_at: '2025-01-01T00:00:00.000Z',
};
seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false });
@@ -112,7 +125,7 @@ describe('InAppNotificationBell', () => {
const { server } = await import('../../../tests/helpers/msw/server');
server.use(
http.get('/api/notifications/in-app', () => HttpResponse.json({ notifications: [], total: 0, unread_count: 0 })),
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 })),
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 }))
);
const user = userEvent.setup();
render(<InAppNotificationBell />);
@@ -144,7 +157,12 @@ describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-012: Delete all button NOT shown when no notifications', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn() });
seedStore(useInAppNotificationStore, {
notifications: [],
unreadCount: 0,
isLoading: false,
fetchNotifications: vi.fn(),
});
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
@@ -153,7 +171,13 @@ describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-013: Mark all read button NOT shown when unreadCount is 0', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: 1 })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() });
seedStore(useInAppNotificationStore, {
notifications: [buildNotification({ is_read: 1 })],
unreadCount: 0,
isLoading: false,
fetchNotifications: vi.fn(),
fetchUnreadCount: vi.fn(),
});
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
@@ -163,7 +187,12 @@ describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-014: clicking Mark all read calls store action', async () => {
const user = userEvent.setup();
const markAllRead = vi.fn();
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false, markAllRead });
seedStore(useInAppNotificationStore, {
notifications: [buildNotification()],
unreadCount: 1,
isLoading: false,
markAllRead,
});
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await user.click(screen.getByTitle('Mark all read'));
@@ -173,7 +202,12 @@ describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-015: clicking Delete all calls store action', async () => {
const user = userEvent.setup();
const deleteAll = vi.fn();
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false, deleteAll });
seedStore(useInAppNotificationStore, {
notifications: [buildNotification()],
unreadCount: 1,
isLoading: false,
deleteAll,
});
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await user.click(screen.getByTitle('Delete all'));
@@ -194,7 +228,12 @@ describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-017: loading spinner shown when isLoading=true and notifications empty', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: true, fetchNotifications: vi.fn() });
seedStore(useInAppNotificationStore, {
notifications: [],
unreadCount: 0,
isLoading: true,
fetchNotifications: vi.fn(),
});
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
const spinner = document.querySelector('.animate-spin');
@@ -1,59 +1,63 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { Bell, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
import { Bell, CheckCheck, Trash2 } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts';
import { useSettingsStore } from '../../store/settingsStore';
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx';
export default function InAppNotificationBell(): React.ReactElement {
const { t } = useTranslation()
const navigate = useNavigate()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const { t } = useTranslation();
const navigate = useNavigate();
const { settings } = useSettingsStore();
const darkMode = settings.dark_mode;
const dark =
darkMode === true ||
darkMode === 'dark' ||
(darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } =
useInAppNotificationStore();
const [open, setOpen] = useState(false)
const [open, setOpen] = useState(false);
useEffect(() => {
if (isAuthenticated) {
fetchUnreadCount()
fetchUnreadCount();
}
}, [isAuthenticated])
}, [isAuthenticated]);
const handleOpen = () => {
if (!open) {
fetchNotifications(true)
fetchNotifications(true);
}
setOpen(v => !v)
}
setOpen((v) => !v);
};
const handleShowAll = () => {
setOpen(false)
navigate('/notifications')
}
setOpen(false);
navigate('/notifications');
};
const displayCount = unreadCount > 99 ? '99+' : unreadCount
const displayCount = unreadCount > 99 ? '99+' : unreadCount;
return (
<div className="relative flex-shrink-0">
<button
onClick={handleOpen}
title={t('notifications.title')}
className="relative p-2 rounded-lg transition-colors"
className="relative rounded-lg p-2 transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Bell className="w-4 h-4" />
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 flex items-center justify-center rounded-full text-white font-bold"
className="absolute -right-0.5 -top-0.5 flex items-center justify-center rounded-full font-bold text-white"
style={{
background: '#ef4444',
fontSize: 9,
@@ -68,104 +72,114 @@ export default function InAppNotificationBell(): React.ReactElement {
)}
</button>
{open && ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div
className="rounded-xl shadow-xl border overflow-hidden"
style={{
position: 'fixed',
top: 'var(--nav-h)',
right: 8,
width: 360,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
background: 'var(--bg-card)',
borderColor: 'var(--border-primary)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
{open &&
ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border-secondary)' }}
className="overflow-hidden rounded-xl border shadow-xl"
style={{
position: 'fixed',
top: 'var(--nav-h)',
right: 8,
width: 360,
maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999,
background: 'var(--bg-card)',
borderColor: 'var(--border-primary)',
display: 'flex',
flexDirection: 'column',
}}
>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{unreadCount}
</span>
)}
</span>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{/* Header */}
<div
className="flex flex-shrink-0 items-center justify-between px-4 py-3"
style={{ borderBottom: '1px solid var(--border-secondary)' }}
>
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span
className="ml-2 rounded-full px-1.5 py-0.5 text-xs font-medium"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}
>
{unreadCount}
</span>
)}
</span>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
title={t('notifications.markAllRead')}
className="rounded-lg p-1.5 transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<CheckCheck className="h-3.5 w-3.5" />
</button>
)}
{notifications.length > 0 && (
<button
onClick={deleteAll}
title={t('notifications.deleteAll')}
className="rounded-lg p-1.5 transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Notification list */}
<div className="flex-1 overflow-y-auto">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div
className="h-5 w-5 animate-spin rounded-full border-2"
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }}
/>
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-4 py-10 text-center">
<Bell className="h-8 w-8" style={{ color: 'var(--text-faint)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>
{t('notifications.empty')}
</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
{t('notifications.emptyDescription')}
</p>
</div>
) : (
notifications
.slice(0, 10)
.map((n) => <InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />)
)}
</div>
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
{isLoading && notifications.length === 0 ? (
<div className="flex items-center justify-center py-10">
<div className="w-5 h-5 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
</div>
) : (
notifications.slice(0, 10).map(n => (
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
))
)}
{/* Footer */}
<button
onClick={handleShowAll}
className="w-full flex-shrink-0 py-2.5 text-xs font-medium transition-colors"
style={{
borderTop: '1px solid var(--border-secondary)',
color: 'var(--text-primary)',
background: 'transparent',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{t('notifications.showAll')}
</button>
</div>
{/* Footer */}
<button
onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
style={{
borderTop: '1px solid var(--border-secondary)',
color: 'var(--text-primary)',
background: 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{t('notifications.showAll')}
</button>
</div>
</>,
document.body
)}
</>,
document.body
)}
</div>
)
);
}
@@ -1,6 +1,6 @@
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import MobileTopHeader from './MobileTopHeader';
@@ -24,9 +24,7 @@ describe('MobileTopHeader', () => {
});
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
render(
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
);
render(<MobileTopHeader title="Trips" actions={<button>Add</button>} />);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -1,17 +1,19 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
title: string;
subtitle?: string;
actions?: React.ReactNode;
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
<div className="flex flex-shrink-0 items-center justify-between bg-zinc-50 px-5 pb-3 pt-4 dark:bg-zinc-950 md:hidden">
<div className="min-w-0 flex-1">
<h1 className="text-[28px] font-extrabold leading-none tracking-tight text-zinc-900 dark:text-white">
{title}
</h1>
{subtitle && <div className="mt-1 text-xs text-zinc-500">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
{actions && <div className="flex flex-shrink-0 items-center gap-2">{actions}</div>}
</div>
)
);
}
+15 -9
View File
@@ -1,22 +1,26 @@
// FE-COMP-NAVBAR-001 to FE-COMP-NAVBAR-028
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildSettings, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import Navbar from './Navbar';
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] }))
);
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
seedStore(useAuthStore, {
user: buildUser({ username: 'testuser', role: 'user' }),
isAuthenticated: true,
appVersion: '2.9.10',
});
seedStore(useSettingsStore, { settings: buildSettings() });
});
@@ -205,9 +209,11 @@ describe('Navbar', () => {
it('FE-COMP-NAVBAR-024: global addon nav links appear when addons enabled', () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
})),
http.get('/api/addons', () =>
HttpResponse.json({
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
})
)
);
seedStore(useAddonStore, {
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
+333 -157
View File
@@ -1,132 +1,225 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import type { LucideIcon } from 'lucide-react';
import {
ArrowLeft,
Briefcase,
CalendarDays,
ChevronDown,
Compass,
Globe,
LogOut,
Moon,
Settings,
Shield,
Sun,
Users,
} from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from '../../i18n';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import InAppNotificationBell from './InAppNotificationBell.tsx';
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass };
interface NavbarProps {
tripTitle?: string
tripId?: string
onBack?: () => void
showBack?: boolean
onShare?: () => void
tripTitle?: string;
tripId?: string;
onBack?: () => void;
showBack?: boolean;
onShare?: () => void;
}
interface Addon {
id: string
name: string
icon: string
type: string
id: string;
name: string;
icon: string;
type: string;
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
const { user, logout, isPrerelease, appVersion } = useAuthStore();
const { settings, updateSetting } = useSettingsStore();
const { addons: allAddons, loadAddons } = useAddonStore();
const { t, locale } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false);
const [scrolled, setScrolled] = useState<boolean>(false);
const darkMode = settings.dark_mode;
const dark =
darkMode === true ||
darkMode === 'dark' ||
(darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
useEffect(() => {
if (user) loadAddons()
}, [user, location.pathname])
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
document.body.addEventListener('scroll', onScroll, { passive: true });
return () => {
window.removeEventListener('scroll', onScroll);
document.body.removeEventListener('scroll', onScroll);
};
}, []);
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled);
useEffect(() => {
if (user) loadAddons();
}, [user, location.pathname]);
const handleLogout = () => {
logout()
navigate('/login', { state: { noRedirect: true } })
}
logout();
navigate('/login', { state: { noRedirect: true } });
};
// Keep track of the pending theme-transition cleanup so we can cancel it
// on unmount. Without this the timer fires after jsdom teardown in unit
// tests (document is gone) and triggers an unhandled ReferenceError that
// trips vitest's exit code.
const themeTransitionTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (themeTransitionTimer.current !== null) {
window.clearTimeout(themeTransitionTimer.current);
themeTransitionTimer.current = null;
}
},
[]
);
const toggleDarkMode = () => {
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
}
document.documentElement.classList.add('trek-theme-transitioning');
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {});
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current);
themeTransitionTimer.current = window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning');
themeTransitionTimer.current = null;
}, 360);
};
const getAddonName = (addon: Addon): string => {
const key = `admin.addons.catalog.${addon.id}.name`
const translated = t(key)
return translated !== key ? translated : addon.name
}
const key = `admin.addons.catalog.${addon.id}.name`;
const translated = t(key);
return translated !== key ? translated : addon.name;
};
return (
<nav style={{
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
<nav
style={{
background: dark
? scrolled
? 'rgba(9,9,11,0.78)'
: 'rgba(9,9,11,0.95)'
: scrolled
? 'rgba(255,255,255,0.72)'
: 'rgba(255,255,255,0.95)',
backdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
WebkitBackdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: scrolled
? dark
? '0 4px 24px rgba(0,0,0,0.35)'
: '0 4px 24px rgba(0,0,0,0.08)'
: dark
? '0 1px 12px rgba(0,0,0,0.2)'
: '0 1px 12px rgba(0,0,0,0.05)',
touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
transition:
'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}}
className="fixed left-0 right-0 top-0 z-[200] hidden items-center gap-4 px-4 md:flex"
>
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
<div className="flex min-w-0 items-center gap-3">
{showBack && (
<button onClick={onBack}
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
<button
onClick={onBack}
className="trek-back-btn flex flex-shrink-0 items-center gap-1.5 rounded-lg p-1.5 text-sm transition-colors"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<ArrowLeft className="w-4 h-4" />
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<ArrowLeft className="trek-back-icon h-4 w-4" />
<span className="hidden sm:inline">{t('common.back')}</span>
</button>
)}
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="TREK" className="sm:hidden" style={{ height: 22, width: 22 }} />
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
<Link to="/dashboard" className="flex flex-shrink-0 items-center transition-colors">
<img
src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'}
alt="TREK"
className="sm:hidden"
style={{ height: 22, width: 22 }}
/>
<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"
<Link
to="/dashboard"
className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
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" />
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => {
if (location.pathname !== '/dashboard') e.currentTarget.style.background = 'transparent';
}}
>
<Briefcase className="h-3.5 w-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
{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"
<Link
key={addon.id}
to={path}
className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
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" />
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden md:inline">{getAddonName(addon)}</span>
</Link>
)
);
})}
</>
)}
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
<span className="hidden sm:inline text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>
/
</span>
<span
className="hidden max-w-48 truncate text-sm font-medium sm:inline"
style={{ color: 'var(--text-muted)' }}
>
{tripTitle}
</span>
</>
@@ -138,12 +231,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{/* Share button */}
{onShare && (
<button onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0"
<button
onClick={onShare}
className="flex flex-shrink-0 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-card)')}
>
<Users className="h-4 w-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
@@ -151,114 +246,195 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{/* Prerelease badge */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
className="hidden flex-shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold sm:flex"
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full" style={{ background: '#f59e0b' }} />
{appVersion}
</span>
)}
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
<button
onClick={toggleDarkMode}
title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="relative hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg p-2 transition-colors sm:flex"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Sun
className="absolute h-4 w-4 transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ opacity: dark ? 1 : 0, transform: dark ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0.6)' }}
/>
<Moon
className="absolute h-4 w-4 transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ opacity: dark ? 0 : 1, transform: dark ? 'rotate(90deg) scale(0.6)' : 'rotate(0deg) scale(1)' }}
/>
</button>
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
{user && tripId && <InAppNotificationBell />}
{user && !tripId && <span className="hidden sm:block"><InAppNotificationBell /></span>}
{user && !tripId && (
<span className="hidden sm:block">
<InAppNotificationBell />
</span>
)}
{/* User menu */}
{user && (
<div className="relative">
<button onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 py-1.5 px-3 rounded-lg transition-colors"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors"
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
<img
src={user.avatar_url}
alt=""
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: dark ? '#e2e8f0' : '#111827', color: dark ? '#0f172a' : '#ffffff' }}>
<div
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold"
style={{ background: dark ? '#e2e8f0' : '#111827', color: dark ? '#0f172a' : '#ffffff' }}
>
{user.username?.charAt(0).toUpperCase()}
</div>
)}
<span className="text-sm hidden sm:inline max-w-24 truncate" style={{ color: 'var(--text-secondary)' }}>
<span className="hidden max-w-24 truncate text-sm sm:inline" style={{ color: 'var(--text-secondary)' }}>
{user.username}
</span>
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
<ChevronDown className="h-4 w-4" style={{ color: 'var(--text-faint)' }} />
</button>
{userMenuOpen && ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
{user.role === 'admin' && (
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1" style={{ color: 'var(--text-secondary)' }}>
<Shield className="w-3 h-3" /> {t('nav.administrator')}
</span>
)}
</div>
{userMenuOpen &&
ReactDOM.createPortal(
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
<div
className="trek-menu-enter w-52 overflow-hidden rounded-xl border shadow-xl"
style={{
position: 'fixed',
top: 'var(--nav-h)',
right: 8,
zIndex: 9999,
background: 'var(--bg-card)',
borderColor: 'var(--border-primary)',
}}
>
<div className="border-b px-4 py-3" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{user.username}
</p>
<p className="truncate text-xs" style={{ color: 'var(--text-muted)' }}>
{user.email}
</p>
{user.role === 'admin' && (
<span
className="mt-1 inline-flex items-center gap-1 text-xs font-medium"
style={{ color: 'var(--text-secondary)' }}
>
<Shield className="h-3 w-3" /> {t('nav.administrator')}
</span>
)}
</div>
<div className="py-1">
<Link to="/settings" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Settings className="w-4 h-4" />
{t('nav.settings')}
</Link>
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
<div className="py-1">
<Link
to="/settings"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Shield className="w-4 h-4" />
{t('nav.admin')}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Settings className="h-4 w-4" />
{t('nav.settings')}
</Link>
)}
</div>
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<button onClick={handleLogout}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors">
<LogOut className="w-4 h-4" />
{t('nav.logout')}
</button>
{appVersion && (
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
{user.role === 'admin' && (
<Link
to="/admin"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Shield className="h-4 w-4" />
{t('nav.admin')}
</Link>
)}
</div>
<div className="border-t py-1" style={{ borderColor: 'var(--border-secondary)' }}>
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-500/10"
>
<LogOut className="h-4 w-4" />
{t('nav.logout')}
</button>
{appVersion && (
<div
className="px-4 pb-2.5 pt-2 text-center"
style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
background: 'var(--bg-tertiary)',
borderRadius: 99,
padding: '4px 12px',
}}
>
<img
src={dark ? '/text-light.svg' : '/text-dark.svg'}
alt="TREK"
style={{ height: 10, opacity: 0.5 }}
/>
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
v{appVersion}
</span>
</div>
<a
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: 99,
background: 'var(--bg-tertiary)',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#5865F220')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-tertiary)')}
title="Discord"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
</a>
</div>
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
title="Discord">
<svg width="12" height="12" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</a>
</div>
</div>
)}
)}
</div>
</div>
</div>
</>,
document.body
)}
</>,
document.body
)}
</div>
)}
</nav>
)
);
}
+55 -49
View File
@@ -1,56 +1,63 @@
/**
* OfflineBanner persistent top bar indicating connectivity + sync state.
* OfflineBanner connectivity + sync state indicator.
*
* States:
* offline + N queued amber bar "Offline N changes queued"
* offline + 0 queued amber bar "Offline"
* online + N pending blue bar "Syncing N changes…"
* offline + N queued amber pill "Offline · N queued"
* offline + 0 queued amber pill "Offline"
* online + N pending blue pill "Syncing N…"
* online + 0 pending hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
import { RefreshCw, WifiOff } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { mutationQueue } from '../../sync/mutationQueue';
const POLL_MS = 3_000
const POLL_MS = 3_000;
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0)
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
const onOnline = () => setIsOnline(true);
const onOffline = () => setIsOnline(false);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);
useEffect(() => {
let cancelled = false
let cancelled = false;
async function poll() {
const n = await mutationQueue.pendingCount()
if (!cancelled) setPendingCount(n)
const n = await mutationQueue.pendingCount();
if (!cancelled) setPendingCount(n);
}
poll()
const id = setInterval(poll, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [])
poll();
const id = setInterval(poll, POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
const hidden = isOnline && pendingCount === 0
if (hidden) return null
const hidden = isOnline && pendingCount === 0;
if (hidden) return null;
const offline = !isOnline
const bg = offline ? '#92400e' : '#1e40af'
const text = '#fff'
const offline = !isOnline;
const bg = offline ? '#92400e' : '#1e40af';
const text = '#fff';
const label = offline
? pendingCount > 0
? `Offline ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
? `Offline · ${pendingCount} queued`
: 'Offline'
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}`
: `Syncing ${pendingCount}`;
return (
<div
@@ -58,29 +65,28 @@ export default function OfflineBanner(): React.ReactElement | null {
aria-live="polite"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
// so the pill sits 16px from the bottom.
bottom: 'calc(var(--bottom-nav-h) + 16px)',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
background: bg,
color: text,
display: 'flex',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
paddingBottom: '6px',
paddingLeft: '16px',
paddingRight: '16px',
fontSize: 13,
fontWeight: 500,
gap: 6,
padding: '6px 14px',
borderRadius: 999,
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
fontSize: 12,
fontWeight: 600,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{offline
? <WifiOff size={14} />
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
}
{offline ? <WifiOff size={12} /> : <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />}
{label}
</div>
)
);
}
@@ -0,0 +1,209 @@
import { Menu, X, type LucideIcon } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
export interface PageSidebarTab {
id: string;
label: string;
icon: LucideIcon;
}
interface PageSidebarProps {
/** Uppercase label shown above the tab list, e.g. "SETTINGS". */
sidebarLabel: string;
tabs: PageSidebarTab[];
activeTab: string;
onTabChange: (id: string) => void;
children: React.ReactNode;
/** Small text at the very bottom of the sidebar (e.g. "v3.0 · self-hosted"). */
footer?: React.ReactNode;
}
/**
* Left-sidebar + right-panel layout used by the Settings and Admin pages.
*
* Desktop (>=1024px): sidebar is always visible at 260px; panel fills rest.
* Mobile: sidebar collapses behind a hamburger at the top of the panel; tap
* the hamburger to slide the sidebar in as an overlay, tap a tab to close.
*/
export default function PageSidebar({
sidebarLabel,
tabs,
activeTab,
onTabChange,
children,
footer,
}: PageSidebarProps): React.ReactElement {
const [mobileOpen, setMobileOpen] = useState(false);
const activeLabel = tabs.find((t) => t.id === activeTab)?.label ?? '';
// Close the mobile drawer on Escape or on outside click.
const drawerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!mobileOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMobileOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mobileOpen]);
return (
<div
className="relative flex flex-col overflow-hidden rounded-2xl lg:flex-row"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
}}
>
{/* Mobile top bar with hamburger */}
<div
className="flex items-center justify-between border-b px-4 py-3 lg:hidden"
style={{ borderColor: 'var(--border-primary)' }}
>
<button
onClick={() => setMobileOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-[var(--bg-hover)]"
aria-label="Open navigation"
style={{ color: 'var(--text-primary)' }}
>
<Menu size={18} />
</button>
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{activeLabel}
</div>
<div className="w-9" />
</div>
{/* Desktop sidebar (always visible on lg) */}
<aside
className="relative hidden shrink-0 flex-col lg:flex"
style={{
width: 260,
background: 'var(--bg-secondary)',
borderRight: '1px solid var(--border-primary)',
padding: '24px 14px',
}}
>
<SidebarInner
sidebarLabel={sidebarLabel}
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
footer={footer}
/>
</aside>
{/* Mobile drawer */}
{mobileOpen && (
<>
<div
className="fixed inset-0 z-40 lg:hidden"
style={{ background: 'rgba(0,0,0,0.35)' }}
onClick={() => setMobileOpen(false)}
/>
<aside
ref={drawerRef}
className="fixed bottom-0 left-0 top-0 z-50 flex flex-col shadow-2xl lg:hidden"
style={{
width: 280,
background: 'var(--bg-secondary)',
padding: '18px 14px',
}}
>
<div className="mb-3 flex items-center justify-between px-2">
<span className="text-[11px] font-bold uppercase tracking-widest" style={{ color: 'var(--text-muted)' }}>
{sidebarLabel}
</span>
<button
onClick={() => setMobileOpen(false)}
className="flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-[var(--bg-hover)]"
aria-label="Close navigation"
style={{ color: 'var(--text-primary)' }}
>
<X size={16} />
</button>
</div>
<SidebarInner
sidebarLabel={null}
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
onTabChange(id);
setMobileOpen(false);
}}
footer={footer}
/>
</aside>
</>
)}
{/* Panel */}
<div className="min-w-0 flex-1" style={{ padding: '26px 28px' }}>
{children}
</div>
</div>
);
}
function SidebarInner({
sidebarLabel,
tabs,
activeTab,
onTabChange,
footer,
}: {
sidebarLabel: string | null;
tabs: PageSidebarTab[];
activeTab: string;
onTabChange: (id: string) => void;
footer?: React.ReactNode;
}): React.ReactElement {
return (
<>
{sidebarLabel && (
<div
className="mb-3 px-3 text-[11px] font-bold uppercase tracking-widest"
style={{ color: 'var(--text-muted)' }}
>
{sidebarLabel}
</div>
)}
<nav className="flex flex-1 flex-col gap-1">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors"
style={{
background: active ? 'var(--bg-hover)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: active ? 600 : 500,
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent';
}}
>
<Icon size={16} className="shrink-0" />
<span className="truncate">{tab.label}</span>
</button>
);
})}
</nav>
{footer && (
<div
className="mt-4 px-3 pt-3 text-[10px] tracking-wide"
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
>
{footer}
</div>
)}
</>
);
}
@@ -0,0 +1,56 @@
import { Locate, LocateFixed, Navigation } from 'lucide-react';
import type { TrackingMode } from '../../hooks/useGeolocation';
interface Props {
mode: TrackingMode;
error: string | null;
onClick: () => void;
// Offset from the bottom edge — callers push this up above the mobile
// bottom nav. Defaults to 20px for desktop.
bottomOffset?: number;
}
// Three-state FAB. Matches the Apple/Google Maps pattern:
// off → outline locate icon
// show → filled locate (blue dot is visible on the map)
// follow → filled navigation arrow (map follows + rotates with heading)
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate;
const isActive = mode !== 'off';
const title = error
? error
: mode === 'off'
? 'Show my location'
: mode === 'show'
? 'Follow my location'
: 'Stop following';
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
style={{
position: 'absolute',
bottom: bottomOffset,
right: 12,
zIndex: 1000,
width: 42,
height: 42,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
color: isActive ? 'white' : error ? '#ef4444' : 'var(--text-muted, #6b7280)',
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.2s, color 0.2s',
}}
>
<Icon size={20} strokeWidth={mode === 'follow' ? 2.5 : 2} />
</button>
);
}
+153 -114
View File
@@ -1,42 +1,43 @@
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService'
import { fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import * as photoService from '../../services/photoService';
const mapMock = vi.hoisted(() => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}));
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position }: any) => (
<div
data-testid="marker"
data-lat={position[0]}
data-lng={position[1]}
onClick={() => eventHandlers?.click?.()}
>
<div data-testid="marker" data-lat={position[0]} data-lng={position[1]} onClick={() => eventHandlers?.click?.()}>
<button
data-testid="marker-hover-trigger"
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
/>
{children}
</div>
),
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />,
useMap: () => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: () => 10,
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}),
}))
useMap: () => mapMock,
useMapEvents: () => ({}),
}));
vi.mock('react-leaflet-cluster', () => ({
default: ({ children }: any) => <div data-testid="cluster-group">{children}</div>,
}))
}));
vi.mock('leaflet', () => ({
default: {
@@ -49,7 +50,7 @@ vi.mock('leaflet', () => ({
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
latLngBounds: vi.fn(() => ({ isValid: () => true })),
point: vi.fn((x: number, y: number) => [x, y]),
}))
}));
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
@@ -57,9 +58,9 @@ vi.mock('../../services/photoService', () => ({
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
}));
import { MapView } from './MapView'
import { MapView } from './MapView';
// Helper: build a place with the extra fields MapView uses (category_name/color/icon)
// that exist on joined DB rows but are not in the base Place TypeScript type.
@@ -70,139 +71,177 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
category_color: null,
category_icon: null,
...overrides,
} as any
} as any;
}
afterEach(() => {
resetAllStores()
})
vi.clearAllMocks();
resetAllStores();
});
describe('MapView', () => {
it('FE-COMP-MAPVIEW-001: renders map container', () => {
render(<MapView />)
expect(screen.getByTestId('map-container')).toBeTruthy()
})
render(<MapView />);
expect(screen.getByTestId('map-container')).toBeTruthy();
});
it('FE-COMP-MAPVIEW-002: renders one marker per place', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, name: 'Louvre', lat: 48.86, lng: 2.337 }),
]
render(<MapView places={places} />)
expect(screen.getAllByTestId('marker').length).toBe(2)
})
];
render(<MapView places={places} />);
expect(screen.getAllByTestId('marker').length).toBe(2);
});
it('FE-COMP-MAPVIEW-003: marker click calls onMarkerClick with place id', () => {
const onMarkerClick = vi.fn()
const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} onMarkerClick={onMarkerClick} />)
fireEvent.click(screen.getByTestId('marker'))
expect(onMarkerClick).toHaveBeenCalledWith(42)
})
const onMarkerClick = vi.fn();
const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })];
render(<MapView places={places} onMarkerClick={onMarkerClick} />);
fireEvent.click(screen.getByTestId('marker'));
expect(onMarkerClick).toHaveBeenCalledWith(42);
});
it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
})
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
const user = userEvent.setup();
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })];
render(<MapView places={places} />);
await user.click(screen.getByTestId('marker-hover-trigger'));
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower');
});
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
const user = userEvent.setup();
const places = [
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
})
];
render(<MapView places={places} />);
await user.click(screen.getByTestId('marker-hover-trigger'));
expect(screen.getByTestId('tooltip').textContent).toContain('Museum');
});
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
render(<MapView route={[[48.0, 2.0], [49.0, 3.0]]} />)
expect(screen.getByTestId('polyline')).toBeTruthy()
})
render(
<MapView
route={[
[
[48.0, 2.0],
[49.0, 3.0],
],
]}
/>
);
expect(screen.getByTestId('polyline')).toBeTruthy();
});
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
render(<MapView route={null} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
render(<MapView route={null} />);
expect(screen.queryByTestId('polyline')).toBeNull();
});
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
render(<MapView route={[[48.0, 2.0]]} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
render(<MapView route={[[[48.0, 2.0]]]} />);
expect(screen.queryByTestId('polyline')).toBeNull();
});
it('FE-COMP-MAPVIEW-009: GPX geometry polyline rendered for place with route_geometry', () => {
const places = [
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('polyline')).toBeTruthy()
})
const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' })];
render(<MapView places={places} />);
expect(screen.getByTestId('polyline')).toBeTruthy();
});
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
expect(screen.getByTestId('cluster-group')).toBeTruthy()
})
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })];
render(<MapView places={places} />);
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 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} />)
];
render(<MapView route={route} routeSegments={routeSegments} />);
// Route polyline is rendered
expect(screen.getByTestId('polyline')).toBeTruthy()
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-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
const places = [
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: 'NOT_VALID_JSON' }),
]
const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: 'NOT_VALID_JSON' })];
// Should not throw; invalid JSON is caught silently
render(<MapView places={places} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
render(<MapView places={places} />);
expect(screen.queryByTestId('polyline')).toBeNull();
});
it('FE-COMP-MAPVIEW-013: route_geometry with fewer than 2 coords skips polyline', () => {
const places = [
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0]]' }),
]
render(<MapView places={places} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
const places = [buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0]]' })];
render(<MapView places={places} />);
expect(screen.queryByTestId('polyline')).toBeNull();
});
it('FE-COMP-MAPVIEW-014: marker icon uses base64 image_url for photo places', () => {
const dataUrl = 'data:image/jpeg;base64,/9j/4AA'
const places = [buildMapPlace({ id: 10, lat: 48.0, lng: 2.0, image_url: dataUrl })]
render(<MapView places={places} />)
const dataUrl = 'data:image/jpeg;base64,/9j/4AA';
const places = [buildMapPlace({ id: 10, lat: 48.0, lng: 2.0, image_url: dataUrl })];
render(<MapView places={places} />);
// Marker still renders; base64 path in createPlaceIcon should be exercised
expect(screen.getByTestId('marker')).toBeTruthy()
})
expect(screen.getByTestId('marker')).toBeTruthy();
});
it('FE-COMP-MAPVIEW-015: uses cached photo thumb from photoService when available', () => {
vi.mocked(photoService.getCached).mockReturnValue({ thumbDataUrl: 'data:image/jpeg;base64,abc' } as any)
const places = [
buildMapPlace({ id: 20, lat: 48.0, lng: 2.0, google_place_id: 'gplace_123' }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('marker')).toBeTruthy()
vi.mocked(photoService.getCached).mockReturnValue(null)
})
vi.mocked(photoService.getCached).mockReturnValue({ thumbDataUrl: 'data:image/jpeg;base64,abc' } as any);
const places = [buildMapPlace({ id: 20, lat: 48.0, lng: 2.0, google_place_id: 'gplace_123' })];
render(<MapView places={places} />);
expect(screen.getByTestId('marker')).toBeTruthy();
vi.mocked(photoService.getCached).mockReturnValue(null);
});
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
const user = userEvent.setup();
const places = [
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
})
];
render(<MapView places={places} />);
await user.click(screen.getByTestId('marker-hover-trigger'));
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France');
});
it('FE-COMP-MAPVIEW-017: renders selected marker with higher z-index offset', () => {
const places = [buildMapPlace({ id: 5, lat: 48.8584, lng: 2.2945 })];
render(<MapView places={places} selectedPlaceId={5} />);
expect(screen.getByTestId('marker')).toBeTruthy();
});
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
const places = [
buildMapPlace({ id: 5, lat: 48.8584, lng: 2.2945 }),
]
render(<MapView places={places} selectedPlaceId={5} />)
expect(screen.getByTestId('marker')).toBeTruthy()
})
})
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
];
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />);
const initialCount = mapMock.fitBounds.mock.calls.length;
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
// paddingOpts memo creates new object). fitBounds must NOT fire again.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />);
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount);
// Toggle selectedPlaceId off — mimics closing inspector via X button.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />);
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount);
});
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })];
const { rerender } = render(<MapView places={places} fitKey={1} />);
const afterFirst = mapMock.fitBounds.mock.calls.length;
rerender(<MapView places={places} fitKey={2} />);
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst);
});
});
+478 -336
View File
@@ -1,57 +1,58 @@
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
import DOM from 'react-dom'
import { renderToStaticMarkup } from 'react-dom/server'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import L from 'leaflet';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Circle, CircleMarker, MapContainer, Marker, Polyline, TileLayer, useMap } from 'react-leaflet';
import MarkerClusterGroup from 'react-leaflet-cluster';
import type { Place, Reservation } from '../../types';
import { CATEGORY_ICON_MAP, getCategoryIcon } from '../shared/categoryIcons';
import ReservationOverlay from './ReservationOverlay';
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'];
try {
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
} catch { return '' }
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }));
} catch {
return '';
}
}
import type { Place } from '../../types'
// Fix default marker icons for vite
delete L.Icon.Default.prototype._getIconUrl
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
})
});
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function escAttr(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const iconCache = new Map<string, L.DivIcon>()
const iconCache = new Map<string, L.DivIcon>();
function createPlaceIcon(place, orderNumbers, isSelected) {
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
const cached = iconCache.get(cacheKey)
if (cached) return cached
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
const borderWidth = isSelected ? 3 : 2.5
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`;
const cached = iconCache.get(cacheKey);
if (cached) return cached;
const size = isSelected ? 44 : 36;
const borderColor = isSelected ? '#111827' : '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)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = place.category_color || '#6b7280'
: '0 2px 8px rgba(0,0,0,0.22)';
const bgColor = place.category_color || '#6b7280';
// Number badges (bottom-right)
let badgeHtml = ''
let badgeHtml = '';
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
const label = orderNumbers.join(' · ');
badgeHtml = `<span style="
position:absolute;bottom:-4px;right:-4px;
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
@@ -63,12 +64,15 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
font-family:-apple-system,system-ui,sans-serif;line-height:1;
box-sizing:border-box;white-space:nowrap;
">${label}</span>`
">${label}</span>`;
}
// Base64 data URL thumbnails — no external image fetch during zoom
// Only use base64 data URLs for markers — external URLs cause zoom lag
if (place.image_url && place.image_url.startsWith('data:')) {
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
// while the thumb is still being generated in the background
if (
place.image_url &&
(place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))
) {
const imgIcon = L.divIcon({
className: '',
html: `<div style="
@@ -88,9 +92,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
iconCache.set(cacheKey, imgIcon)
return imgIcon
});
iconCache.set(cacheKey, imgIcon);
return imgIcon;
}
const fallbackIcon = L.divIcon({
@@ -110,139 +114,131 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
iconCache.set(cacheKey, fallbackIcon)
return fallbackIcon
});
iconCache.set(cacheKey, fallbackIcon);
return fallbackIcon;
}
interface SelectionControllerProps {
places: Place[]
selectedPlaceId: number | null
dayPlaces: Place[]
paddingOpts: Record<string, number>
places: Place[];
selectedPlaceId: number | null;
dayPlaces: Place[];
paddingOpts: Record<string, number>;
}
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
const map = useMap()
const prev = useRef(null)
const map = useMap();
const prev = useRef(null);
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom
const selected = places.find(p => p.id === selectedPlaceId)
const selected = places.find((p) => p.id === selectedPlaceId);
if (selected?.lat && selected?.lng) {
map.panTo([selected.lat, selected.lng], { animate: true })
map.panTo([selected.lat, selected.lng], { animate: true });
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, map])
prev.current = selectedPlaceId;
}, [selectedPlaceId, places, map]);
return null
return null;
}
interface MapControllerProps {
center: [number, number]
zoom: number
center: [number, number];
zoom: number;
}
function MapController({ center, zoom }: MapControllerProps) {
const map = useMap()
const prevCenter = useRef(center)
const map = useMap();
const prevCenter = useRef(center);
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
prevCenter.current = center
map.setView(center, zoom);
prevCenter.current = center;
}
}, [center, zoom, map])
}, [center, zoom, map]);
return null
return null;
}
// Fit bounds when places change (fitKey triggers re-fit)
interface BoundsControllerProps {
hasDayDetail?: boolean
places: Place[]
fitKey: number
paddingOpts: Record<string, number>
hasDayDetail?: boolean;
places: Place[];
fitKey: number;
paddingOpts: Record<string, number>;
}
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const map = useMap();
const prevFitKey = useRef(-1);
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
if (places.length === 0) return
if (fitKey === prevFitKey.current) return;
prevFitKey.current = fitKey;
if (places.length === 0) return;
try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
const bounds = L.latLngBounds(places.map((p) => [p.lat, p.lng]));
if (bounds.isValid()) {
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true });
if (hasDayDetail) {
setTimeout(() => map.panBy([0, 150], { animate: true }), 300)
setTimeout(() => map.panBy([0, 150], { animate: true }), 300);
}
}
} catch {}
}, [fitKey, places, paddingOpts, map, hasDayDetail])
}, [fitKey]); // eslint-disable-line react-hooks/exhaustive-deps
return null
return null;
}
interface MapClickHandlerProps {
onClick: ((e: L.LeafletMouseEvent) => void) | null
onClick: ((e: L.LeafletMouseEvent) => void) | null;
}
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
const map = useMap()
const map = useMap();
useEffect(() => {
map.on('zoomstart', onZoomStart)
map.on('zoomend', onZoomEnd)
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
}, [map, onZoomStart, onZoomEnd])
return null
map.on('zoomstart', onZoomStart);
map.on('zoomend', onZoomEnd);
return () => {
map.off('zoomstart', onZoomStart);
map.off('zoomend', onZoomEnd);
};
}, [map, onZoomStart, onZoomEnd]);
return null;
}
function MapClickHandler({ onClick }: MapClickHandlerProps) {
const map = useMap()
const map = useMap();
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => map.off('click', onClick)
}, [map, onClick])
return null
if (!onClick) return;
map.on('click', onClick);
return () => map.off('click', onClick);
}, [map, onClick]);
return null;
}
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
const map = useMap()
const map = useMap();
useEffect(() => {
if (!onContextMenu) return
map.on('contextmenu', onContextMenu)
return () => map.off('contextmenu', onContextMenu)
}, [map, onContextMenu])
return null
if (!onContextMenu) return;
map.on('contextmenu', onContextMenu);
return () => map.off('contextmenu', onContextMenu);
}, [map, onContextMenu]);
return null;
}
// ── Route travel time label ──
interface RouteLabelProps {
midpoint: [number, number]
walkingText: string
drivingText: string
midpoint: [number, number];
walkingText: string;
drivingText: string;
}
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 12)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)
}, [map])
if (!visible || !midpoint) return null
if (!midpoint) return null;
const icon = L.divIcon({
className: 'route-info-pill',
@@ -268,104 +264,132 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
})
});
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />;
}
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { useGeolocation } from '../../hooks/useGeolocation';
import { fetchPhoto, getAllThumbs, getCached, isLoading, onThumbReady } from '../../services/photoService';
import { useAuthStore } from '../../store/authStore';
import LocationButton from './LocationButton';
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
function LocationTracker() {
const map = useMap()
const [position, setPosition] = useState<[number, number] | null>(null)
const [accuracy, setAccuracy] = useState(0)
const [tracking, setTracking] = useState(false)
const watchId = useRef<number | null>(null)
// Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
// identically. Heading is shown as a rotated conic SVG when available.
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
const startTracking = useCallback(() => {
if (!('geolocation' in navigator)) return
setTracking(true)
watchId.current = navigator.geolocation.watchPosition(
(pos) => {
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
setPosition(latlng)
setAccuracy(pos.coords.accuracy)
},
() => setTracking(false),
{ enableHighAccuracy: true, maximumAge: 5000 }
)
}, [])
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap();
const stopTracking = useCallback(() => {
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
watchId.current = null
setTracking(false)
setPosition(null)
}, [])
const toggleTracking = useCallback(() => {
if (tracking) { stopTracking() } else { startTracking() }
}, [tracking, startTracking, stopTracking])
// Center map on position when first acquired
const centered = useRef(false)
// When the user is in follow mode, keep the map centred on the dot.
// setView (no animation) is what Google Maps does during navigation —
// it feels responsive and avoids animation jitter at walking speed.
useEffect(() => {
if (position && !centered.current) {
map.setView(position, 15)
centered.current = true
if (mode !== 'follow' || !position) return;
try {
map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 });
} catch {
/* noop */
}
}, [position, map])
}, [position, mode, map]);
// Cleanup on unmount
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
// Once, when the user first acquires a fix in "show" mode, pan to it so
// they don't have to scroll the map. Subsequent fixes only move the dot.
const centeredRef = useRef(false);
useEffect(() => {
if (mode === 'off') {
centeredRef.current = false;
return;
}
if (!position || centeredRef.current) return;
try {
map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15));
} catch {
/* noop */
}
centeredRef.current = true;
}, [position, mode, map]);
if (!position) return null;
const headingIcon =
position.heading === null || Number.isNaN(position.heading)
? null
: L.divIcon({
className: '',
iconSize: [60, 60],
iconAnchor: [30, 30],
html: `<div style="
width:60px;height:60px;
transform:rotate(${position.heading}deg);transition:transform 120ms ease-out;
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
border-radius:50%;
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
mask:radial-gradient(circle, transparent 12px, black 13px);
pointer-events:none;
"></div>`,
});
return (
<>
{/* Location button */}
<div style={{
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
}}>
<button onClick={toggleTracking} style={{
width: 36, height: 36, borderRadius: '50%',
border: 'none', cursor: 'pointer',
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.2s, color 0.2s',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
</svg>
</button>
</div>
{/* Blue dot + accuracy circle */}
{position && (
<>
{accuracy < 500 && (
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
)}
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
</>
{position.accuracy < 500 && (
<Circle
center={[position.lat, position.lng]}
radius={position.accuracy}
pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.12, weight: 1, opacity: 0.35 }}
interactive={false}
/>
)}
{/* Pulse animation CSS */}
{position && (
<style>{`
@keyframes location-pulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.5); opacity: 0; }
}
`}</style>
{headingIcon && (
<Marker position={[position.lat, position.lng]} icon={headingIcon} interactive={false} zIndexOffset={900} />
)}
<CircleMarker
center={[position.lat, position.lng]}
radius={8}
pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 3 }}
interactive={false}
/>
</>
)
);
}
interface MemoMarkerProps {
place: any;
isSelected: boolean;
orderNumbers: number[] | null;
photoUrl: string | null;
onClickPlace: (id: number) => void;
onHover: (place: any, x: number, y: number) => void;
onHoverOut: () => void;
}
const MemoMarker = memo(function MemoMarker({
place,
isSelected,
orderNumbers,
photoUrl,
onClickPlace,
onHover,
onHoverOut,
}: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected);
return (
<Marker
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onClickPlace(place.id),
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
mouseout: onHoverOut,
}}
zIndexOffset={isSelected ? 1000 : 0}
/>
);
});
export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
@@ -384,191 +408,309 @@ export const MapView = memo(function MapView({
rightWidth = 0,
hasInspector = false,
hasDayDetail = false,
}) {
reservations = [] as Reservation[],
showReservationStats = false,
visibleConnectionIds = [] as number[],
onReservationClick,
}: any) {
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [];
const set = new Set(visibleConnectionIds);
return reservations.filter((r: Reservation) => set.has(r.id));
}, [reservations, visibleConnectionIds]);
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (isMobile) return { padding: [40, 20] }
const top = 60
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
const left = leftWidth + 40
const right = rightWidth + 40
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
if (isMobile) return { padding: [40, 20] };
const top = 60;
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60;
const left = leftWidth + 40;
const right = rightWidth + 40;
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] };
}, [leftWidth, rightWidth, hasInspector, hasDayDetail]);
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<any>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
setHoveredPlace(place);
setTooltipPos({ x, y });
}, []);
const handleMarkerHoverOut = useCallback(() => {
setHoveredPlace(null);
}, []);
const handleMarkerClick = useCallback(
(id: number) => {
onMarkerClick?.(id);
},
[onMarkerClick]
);
// photoUrls: only base64 thumbs for smooth map zoom
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs);
const placesPhotosEnabled = useAuthStore((s) => s.placesPhotosEnabled);
// Batch photo state updates through a RAF so N simultaneous photo loads
// collapse into a single re-render instead of N separate renders.
const pendingThumbsRef = useRef<Record<string, string>>({});
const thumbRafRef = useRef<number | null>(null);
// Fetch photos via shared service — subscribe to thumb (base64) availability
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
const placeIds = useMemo(() => places.map((p) => p.id).join(','), [places]);
useEffect(() => {
if (!places || places.length === 0) return
const cleanups: (() => void)[] = []
if (!places || places.length === 0 || !placesPhotosEnabled) return;
const cleanups: (() => void)[] = [];
const setThumb = (cacheKey: string, thumb: string) => {
iconCache.clear()
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
}
pendingThumbsRef.current[cacheKey] = thumb;
if (thumbRafRef.current !== null) return;
thumbRafRef.current = requestAnimationFrame(() => {
thumbRafRef.current = null;
const pending = pendingThumbsRef.current;
pendingThumbsRef.current = {};
setPhotoUrls((prev) => {
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v);
return hasChange ? { ...prev, ...pending } : prev;
});
});
};
for (const place of places) {
if (place.image_url && place.image_url.startsWith('data:')) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) continue
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`;
if (!cacheKey) continue;
const cached = getCached(cacheKey)
const cached = getCached(cacheKey);
if (cached?.thumbDataUrl) {
setThumb(cacheKey, cached.thumbDataUrl)
continue
setThumb(cacheKey, cached.thumbDataUrl);
continue;
}
// Subscribe for when thumb becomes available
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
cleanups.push(onThumbReady(cacheKey, (thumb) => setThumb(cacheKey, thumb)));
// Always fetch through API — returns fresh URL + converts to base64
if (!cached && !isLoading(cacheKey)) {
const photoId = place.google_place_id || place.osm_id
const photoId =
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null) ||
place.google_place_id ||
place.osm_id ||
place.image_url;
if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name);
}
}
}
return () => cleanups.forEach(fn => fn())
}, [placeIds])
return () => {
cleanups.forEach((fn) => fn());
if (thumbRafRef.current !== null) {
cancelAnimationFrame(thumbRafRef.current);
thumbRafRef.current = null;
}
};
}, [placeIds, placesPhotosEnabled]);
const clusterIconCreateFunction = useCallback((cluster) => {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
const count = cluster.getChildCount();
const size = count < 10 ? 36 : count < 50 ? 42 : 48;
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
iconSize: L.point(size, size),
})
}, [])
});
}, []);
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0;
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
const orderNumbers = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
return (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
permanent={isTouchDevice && isSelected}
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
return (
<MapContainer
id="trek-map"
center={center}
zoom={zoom}
zoomControl={false}
className="w-full h-full"
style={{ background: '#e5e7eb' }}
>
<TileLayer
url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxZoom={19}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<LocationTracker />
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
{route && route.length > 1 && (
<>
<Polyline
positions={route}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
const markers = useMemo(
() =>
places.map((place) => {
const isSelected = place.id === selectedPlaceId;
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`;
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null;
const orderNumbers = dayOrderMap[place.id] ?? null;
return (
<MemoMarker
key={place.id}
place={place}
isSelected={isSelected}
orderNumbers={orderNumbers}
photoUrl={photoUrl}
onClickPlace={handleMarkerClick}
onHover={handleMarkerHover}
onHoverOut={handleMarkerHoverOut}
/>
{routeSegments.map((seg, i) => (
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
))}
</>
)}
);
}),
[places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut]
);
{/* GPX imported route geometries */}
{places.map((place) => {
if (!place.route_geometry) return null
const gpxPolylines = useMemo(
() =>
places.flatMap((place) => {
if (!place.route_geometry) return [];
try {
const coords = JSON.parse(place.route_geometry) as [number, number][]
if (!coords || coords.length < 2) return null
return (
const coords = JSON.parse(place.route_geometry) as [number, number][];
if (!coords || coords.length < 2) return [];
return [
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)
} catch { return null }
})}
</MapContainer>
)
})
/>,
];
} catch {
return [];
}
}),
[places]
);
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice;
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null;
const {
position: userPosition,
mode: trackingMode,
error: trackingError,
cycleMode: cycleTrackingMode,
} = useGeolocation();
// Desktop browsers only get IP-based geolocation (city-level accuracy),
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)';
return (
<>
<div className="relative h-full w-full">
<MapContainer
id="trek-map"
center={center}
zoom={zoom}
zoomControl={false}
className="h-full w-full"
style={{ background: '#e5e7eb' }}
>
<TileLayer
url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxZoom={19}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
<MapController center={center} zoom={zoom} />
<BoundsController
places={dayPlaces.length > 0 ? dayPlaces : places}
fitKey={fitKey}
paddingOpts={paddingOpts}
hasDayDetail={hasDayDetail}
/>
<SelectionController
places={places}
selectedPlaceId={selectedPlaceId}
dayPlaces={dayPlaces}
paddingOpts={paddingOpts}
/>
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{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}
/>
))}
</>
)}
{/* GPX imported route geometries */}
{gpxPolylines}
<ReservationOverlay
reservations={visibleReservations}
showConnections
showStats={showReservationStats}
onEndpointClick={onReservationClick}
/>
</MapContainer>
{isMobile && (
<LocationButton
mode={trackingMode}
error={trackingError}
onClick={cycleTrackingMode}
bottomOffset={locationButtonBottom as unknown as number}
/>
)}
</div>
{TooltipOverlay && (
<div
data-testid="tooltip"
style={{
position: 'fixed',
left: tooltipPos.x + 14,
top: tooltipPos.y - 10,
zIndex: 9999,
pointerEvents: 'none',
background: 'white',
borderRadius: 8,
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
padding: '6px 10px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
maxWidth: 220,
whiteSpace: 'nowrap',
}}
>
<div
style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{hoveredPlace.name}
</div>
{hoveredPlace.category_name && CatIcon && (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
</div>
)}
{hoveredPlace.address && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hoveredPlace.address}
</div>
)}
</div>
)}
</>
);
});

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