Compare commits

...

19 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
562 changed files with 115578 additions and 63246 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules
client/node_modules
server/node_modules
client/dist
shared/dist
data
uploads
.git
+3 -4
View File
@@ -102,16 +102,15 @@ jobs:
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
+45 -7
View File
@@ -8,10 +8,33 @@ on:
branches: [main, dev]
paths:
- 'server/**'
- '.github/workflows/test.yml'
- 'client/**'
- 'shared/**'
- '.github/workflows/test.yml'
jobs:
shared-contracts:
name: Shared Contracts (Zod)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
@@ -21,12 +44,24 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
cache-dependency-path: server/package-lock.json
cache-dependency-path: package-lock.json
- name: Install dependencies
run: cd server && npm ci
run: npm ci --workspace shared && npm ci --workspace server
- name: Build shared
run: npm run build --workspace=shared
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Typecheck (informational)
# Pre-existing type errors in the NestJS rewrite; surfaces them without
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
continue-on-error: true
run: cd server && npm run typecheck
- name: Run tests
run: cd server && npm run test:coverage
@@ -48,12 +83,15 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
cache-dependency-path: client/package-lock.json
cache-dependency-path: package-lock.json
- name: Install dependencies
run: cd client && npm ci
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests
run: cd client && npm run test:coverage
+2
View File
@@ -3,6 +3,8 @@ node_modules/
# Build output
client/dist/
server/dist/
shared/dist/
server/public/*
!server/public/.gitkeep
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
+49 -19
View File
@@ -1,31 +1,60 @@
# Stage 1: Build React client
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
RUN npm ci --workspace=shared
COPY shared/ ./shared/
RUN npm run build --workspace=shared
# ── Stage 2: client ──────────────────────────────────────────────────────────
FROM node:24-alpine AS client-builder
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
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 2: Production server
# ── 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 ./
# Workspace manifests only — source never enters this stage.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools; purged after install.
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \
rm package-lock.json && \
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 server/ ./
COPY --from=client-builder /app/client/dist ./public
COPY --from=client-builder /app/client/public/fonts ./public/fonts
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 rm -f package-lock.json && \
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 && \
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
ln -s /app/uploads /app/server/uploads && \
ln -s /app/data /app/server/data && \
chown -R node:node /app
ENV NODE_ENV=production
@@ -39,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
+1 -1
View File
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
+1 -1
View File
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
2. Email: **report@liketrek.com**
3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.18
version: 3.0.22
description: Minimal Helm chart for TREK app
appVersion: "3.0.18"
appVersion: "3.0.22"
+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',
},
},
])
-11079
View File
File diff suppressed because it is too large Load Diff
+19 -4
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.18",
"name": "@trek/client",
"version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
@@ -12,12 +12,17 @@
"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",
@@ -34,6 +39,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -56,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());
});
});
+168 -134
View File
@@ -1,208 +1,242 @@
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 { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
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 { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
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'
import './pages/Trips/noticeActions.js';
interface ProtectedRouteProps {
children: ReactNode
adminRequired?: boolean
addonId?: string
children: ReactNode;
adminRequired?: boolean;
addonId?: string;
}
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()
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 + location.hash)
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 <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, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
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; 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)
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()
loadAddons()
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')
const isAuthPage =
location.pathname.startsWith('/login') ||
location.pathname.startsWith('/register') ||
location.pathname.startsWith('/forgot-password') ||
location.pathname.startsWith('/reset-password');
return (
<TranslationProvider>
@@ -302,5 +336,5 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
)
);
}
+18 -5
View File
@@ -1,4 +1,5 @@
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'
@@ -209,7 +210,7 @@ export const oauthApi = {
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
@@ -407,8 +408,20 @@ export const journeyApi = {
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),
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).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),
@@ -489,8 +502,8 @@ 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 = {
@@ -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);
});
+356 -172
View File
@@ -1,168 +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, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } 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" />
<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" />
<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} />;
}
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
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
{
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)
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>
@@ -175,61 +250,100 @@ 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="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">
{COLLAB_SUB_FEATURES.map(feat => {
const enabled = collabFeatures[feat.key]
const Icon = feat.icon
{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="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
<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 items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? '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: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}
>
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button onClick={() => onToggleCollabFeature(feat.key)}
<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)' }} />
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>
@@ -242,43 +356,68 @@ 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 => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
{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="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
<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>
@@ -291,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>
@@ -306,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>
)
);
}
@@ -1,12 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Settings2 } from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import Section from '../Settings/Section'
import CustomSelect from '../shared/CustomSelect'
import { MapView } from '../Map/MapView'
import type { Place } from '../../types'
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' },
@@ -14,35 +14,31 @@ const MAP_PRESETS = [
{ 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
}
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
}) {
function OptionRow({ label, hint, children }: { label: React.ReactNode; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
<label className="mb-2 block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{label}
</label>
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
<div className="flex gap-3 flex-wrap">{children}</div>
{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({
@@ -50,17 +46,23 @@ function OptionButton({
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
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,
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)',
@@ -69,89 +71,103 @@ function OptionButton({
>
{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('')
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))
}, [])
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'))
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'))
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'))
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'))
toast.error(err instanceof Error ? err.message : t('common.error'));
}
}
};
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
const isSet = (key: keyof Defaults) => defaults[key] !== undefined;
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
onClick={() => reset(field)}
className="text-xs ml-2"
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
className="ml-2 text-xs"
style={{
color: 'var(--text-faint)',
textDecoration: 'underline',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
) : 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(),
}], [])
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>
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p>;
}
const darkMode = defaults.dark_mode
const darkMode = defaults.dark_mode;
return (
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
@@ -160,15 +176,27 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</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 => (
<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)}
active={
darkMode === opt.value ||
(opt.value === 'light' && darkMode === false) ||
(opt.value === 'dark' && darkMode === true)
}
onClick={() => save({ dark_mode: opt.value })}
>
{opt.label}
@@ -177,11 +205,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</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 => (
<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}
@@ -193,11 +229,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</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 => (
<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}
@@ -209,11 +253,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</OptionRow>
{/* Route Calculation */}
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<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}
@@ -225,11 +277,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</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 => (
<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}
@@ -242,15 +302,20 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* Map Tile URL */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
<label className="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 }) } }}
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 }))}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
@@ -260,9 +325,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
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 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
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="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
<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, {
@@ -286,5 +353,5 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</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();
@@ -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 />);
+411 -223
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
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,17 +202,38 @@ 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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
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>
@@ -166,38 +241,82 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
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,82 +26,82 @@ 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
@@ -109,50 +109,50 @@ export default function PermissionsPanel(): React.ReactElement {
disabled={saving}
title={t('perm.resetDefaults')}
aria-label={t('perm.resetDefaults')}
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
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" />
<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),
}))}
@@ -160,7 +160,7 @@ export default function PermissionsPanel(): React.ReactElement {
/>
</div>
</div>
)
);
})}
</div>
</div>
@@ -168,5 +168,5 @@ export default function PermissionsPanel(): React.ReactElement {
</div>
</div>
</div>
)
);
}
+66 -100
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,9 +370,7 @@ 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');
@@ -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();
});
});
+120 -81
View File
@@ -1,85 +1,95 @@
import { useState, useEffect, useMemo } 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
chat: boolean;
notes: boolean;
polls: boolean;
whatsnext: boolean;
}
interface CollabPanelProps {
tripId: number
tripMembers?: TripMember[]
collabFeatures?: CollabFeatures
tripId: number;
tripMembers?: TripMember[];
collabFeatures?: CollabFeatures;
}
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 },
]
{
id: 'next',
featureKey: 'whatsnext' as const,
labelKey: 'collab.whatsNext.title',
fallback: "What's Next",
icon: Sparkles,
},
];
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
const { user } = useAuthStore()
const { t } = useTranslation()
const isDesktop = useIsDesktop()
const { user } = useAuthStore();
const { t } = useTranslation();
const isDesktop = useIsDesktop();
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
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 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')
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)
if (tabs.length > 0 && !tabs.some((t) => t.id === mobileTab)) {
setMobileTab(tabs[0].id);
}
}, [tabs, mobileTab])
}, [tabs, mobileTab]);
const chatOn = features.chat
const rightPanels = [
features.notes && 'notes',
features.polls && 'polls',
features.whatsnext && 'whatsnext',
].filter(Boolean) as string[]
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 (tabs.length === 0) return null;
if (isDesktop) {
// Chat always 380px fixed when on. Right panels share remaining space.
@@ -92,7 +102,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<CollabChat tripId={tripId} currentUser={user} />
</div>
</div>
)
);
}
if (chatOn) {
@@ -110,13 +120,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{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 === 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 }}>
@@ -134,11 +145,11 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
)}
</div>
</div>
)
);
}
// Chat off — remaining panels share full width
const panels = rightPanels
const panels = rightPanels;
if (panels.length === 1) {
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
@@ -148,12 +159,12 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
</div>
)
);
}
return (
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
{panels.map(p => (
{panels.map((p) => (
<div key={p} style={{ ...card, flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
@@ -161,30 +172,58 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
</div>
))}
</div>
)
);
}
// 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 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>
@@ -195,5 +234,5 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{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);
+204 -153
View File
@@ -1,49 +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
dayColor: string
dayLabel: number
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
invalidateSize: () => 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
dayColor?: string
dayLabel?: number
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
fullScreen?: boolean
paddingBottom?: number
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({
@@ -55,23 +55,23 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
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(dayColor: string, dayLabel: number, highlighted: boolean): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)';
const shadow = highlighted
? '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
: '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">
@@ -79,86 +79,96 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
<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, 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) {
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)
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) {
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)
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) {
try {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
} catch { /* map not yet initialized */ }
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 });
} catch {
/* map not yet initialized */
}
}
}, [])
}, []);
const invalidateSize = useCallback(() => {
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
}, [])
try {
mapRef.current?.invalidateSize();
} catch {
/* map not yet initialized */
}
}, []);
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
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,
@@ -166,12 +176,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
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>',
@@ -182,142 +192,183 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
// updates and keep a larger ring of off-screen tiles ready.
updateWhenIdle: false,
keepBuffer: 4,
} as any).addTo(map)
} 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 — only in non-fullscreen (sidebar map) mode
if (!fullScreen && items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
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(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) {
const pb = paddingBottom || 50
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
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, fullScreen, paddingBottom])
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) return
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 })
const currentZoom = mapRef.current.getZoom();
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 });
} catch {
mapRef.current.setView(marker.getLatLng(), 12)
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;
@@ -1,57 +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'
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
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
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
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)
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
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])
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)} />
return <JourneyMapGL ref={glRef} {...(props as any)} />;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <JourneyMap ref={leafletRef} {...(props as any)} />
})
return <JourneyMap ref={leafletRef} {...(props as any)} />;
});
export default JourneyMapAuto
export default JourneyMapAuto;
+269 -204
View File
@@ -1,55 +1,61 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
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
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
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
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
id: string;
lat: number;
lng: number;
label: string;
locationName: string;
time: string;
dayColor: string;
dayLabel: number;
}
const MARKER_W = 28
const MARKER_H = 36
const MARKER_W = 28;
const MARKER_H = 36;
function buildItems(entries: MapEntry[]): Item[] {
const items: Item[] = []
const items: Item[] = [];
for (const e of entries) {
if (e.lat && e.lng) {
items.push({
@@ -61,11 +67,11 @@ function buildItems(entries: MapEntry[]): Item[] {
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;
}
function escapeHtml(s: string): string {
@@ -74,26 +80,26 @@ function escapeHtml(s: string): string {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/'/g, '&#39;');
}
function formatEntryDate(iso: string): string {
if (!iso) return ''
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)
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
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'
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 {
@@ -159,93 +165,94 @@ function ensureJourneyPopupStyle() {
from { opacity: 0; }
to { opacity: 1; }
}
`
document.head.appendChild(s)
`;
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 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)
: '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};`
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
</svg>`;
wrap.appendChild(inner);
return wrap;
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
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 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()
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 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 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]
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)
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,
@@ -258,78 +265,98 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
})
.setLngLat([item.lng, item.lat])
.setHTML(html)
.addTo(mapRef.current)
.addTo(mapRef.current);
}
}, [])
}, []);
const hidePopup = useCallback(() => {
if (popupRef.current) {
try { popupRef.current.remove() } catch { /* noop */ }
popupRef.current = null
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
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 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 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 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 */ }
}, [])
try {
mapRef.current?.resize();
} catch {
/* map not yet ready */
}
}, []);
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [highlightMarker, focusMarker, invalidateSize])
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
if (!containerRef.current || !mapboxToken) return;
mapboxgl.accessToken = mapboxToken;
const items = buildItems(entries)
itemsRef.current = items
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 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,
@@ -340,31 +367,42 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
attributionControl: true,
antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
});
mapRef.current = map;
map.on('load', () => {
if (mapbox3d) {
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
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 */ }
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,
})
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 },
})
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
},
});
map.addLayer({
id: 'journey-route-line',
type: 'line',
@@ -376,88 +414,115 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
'line-dasharray': [2, 3],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
});
}
}
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
const el = markerHtml(item.dayColor, item.dayLabel, false);
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
.addTo(map);
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onMarkerClickRef.current?.(item.id)
})
markersRef.current.set(item.id, marker)
})
ev.stopPropagation();
onMarkerClickRef.current?.(item.id);
});
markersRef.current.set(item.id, marker);
});
// fit bounds to all points
if (hasPoints) {
const pb = paddingBottom || 50
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 */ }
});
} catch {
/* empty bounds */
}
}
})
});
return () => {
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
markersRef.current.forEach((m) => m.remove());
markersRef.current.clear();
if (popupRef.current) {
try { popupRef.current.remove() } catch { /* noop */ }
popupRef.current = null
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])
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
if (!activeMarkerId || !mapRef.current) return;
const t = setTimeout(() => {
highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId)
if (!marker || !mapRef.current) return
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])
});
} 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 dark:bg-zinc-800 text-center px-6"
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 />
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
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
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>
)
);
}
@@ -1,20 +1,33 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
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,
@@ -23,103 +36,123 @@ const WEATHER_ICONS: Record<string, typeof Sun> = {
rainy: CloudRain,
stormy: CloudLightning,
cold: Snowflake,
}
};
function photoUrl(p: JourneyPhoto): string {
return `/api/photos/${p.photo_id}/thumbnail`
return `/api/photos/${p.photo_id}/thumbnail`;
}
function stripMarkdown(text: string): string {
return text
.replace(/[#*_~`>\[\]()!|-]/g, '')
.replace(/\n+/g, ' ')
.trim()
.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
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 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
: null;
const date = new Date(entry.entry_date + 'T00:00:00')
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
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) : ''
const storyPreview = entry.story ? stripMarkdown(entry.story) : '';
return (
<button
onClick={onClick}
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
className={`flex-shrink-0 overflow-hidden rounded-xl text-left transition-all duration-100 ${
isActive
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
? '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]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
<img
src={thumbSrc}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
<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 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
<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-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
<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-1 p-3 flex flex-col min-w-0">
<div className="flex min-w-0 flex-1 flex-col p-3">
{/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
<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] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && (
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
)}
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
<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 items-center justify-center w-5 h-5 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'
}`}>
<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 items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
<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>
)}
@@ -127,30 +160,31 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
</div>
{/* Title */}
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
<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="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
<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="flex items-center gap-1 mt-auto">
<div className="mt-auto flex items-center gap-1">
{hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<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] text-zinc-400 italic">No location</span>
<span className="text-[10px] italic text-zinc-400">No location</span>
)}
</div>
</div>
</div>
</button>
)
);
}
+128 -71
View File
@@ -1,78 +1,132 @@
import { useState } from 'react'
import {
X, Pencil, Trash2, MapPin, Clock, Camera,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react'
import JournalBody from './JournalBody'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
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' },
}
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' },
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' },
}
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}`
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
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
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' })
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-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
<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 items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
<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="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
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="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
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="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
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>
@@ -81,32 +135,31 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
<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="w-full max-h-[50vh] object-cover cursor-pointer"
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 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
<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 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
<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="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
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)}
/>
))}
@@ -117,9 +170,8 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{/* Content */}
<div className="px-5 py-5 pb-32">
{/* Date + time + location header */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<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">
@@ -131,8 +183,8 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{entry.location_name && (
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
<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>
@@ -140,22 +192,24 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{/* Title */}
{entry.title && (
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
<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="flex items-center gap-2 mb-4">
<div className="mb-4 flex items-center gap-2">
{mood && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
<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 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<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>
@@ -165,16 +219,19 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{/* Story */}
{entry.story && (
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
<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="flex flex-wrap gap-1.5 mb-5">
<div className="mb-5 flex flex-wrap gap-1.5">
{entry.tags.map((tag, i) => (
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
<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>
))}
@@ -183,16 +240,16 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{/* Pros & Cons */}
{hasProscons && (
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
<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="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
<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="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
<span className="text-emerald-500 mt-0.5">+</span> {p}
<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>
@@ -203,13 +260,13 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
)}
{consArr.length > 0 && (
<div className="px-4 py-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
<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="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span> {c}
<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>
@@ -220,5 +277,5 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
</div>
</div>
</div>
)
);
}
@@ -1,30 +1,30 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
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
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
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({
@@ -38,108 +38,122 @@ export default function MobileMapTimeline({
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 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>()
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())
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 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])
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
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])
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 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 })
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])
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' })
}, [])
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])
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])
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)
const timer = setTimeout(() => syncMapToCarousel(0), 500);
return () => clearTimeout(timer);
}
}, [entries.length])
}, [entries.length]);
const activeEntryId = entries[activeIndex]
? String(entries[activeIndex].id)
: null
const activeEntryId = entries[activeIndex] ? String(entries[activeIndex].id) : null;
if (entries.length === 0) {
return (
@@ -161,14 +175,14 @@ export default function MobileMapTimeline({
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
<button
onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
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 (
@@ -191,10 +205,7 @@ export default function MobileMapTimeline({
/>
{/* Bottom carousel */}
<div
className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
>
<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"
@@ -209,7 +220,10 @@ export default function MobileMapTimeline({
<div
key={entry.id}
data-idx={i}
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
ref={(node) => {
if (node) cardRefs.current.set(i, node);
else cardRefs.current.delete(i);
}}
style={{ scrollSnapAlign: 'center' }}
>
<MobileEntryCard
@@ -227,18 +241,15 @@ export default function MobileMapTimeline({
{/* 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)' }}
>
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}>
<button
onClick={onAddEntry}
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
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';
+155 -75
View File
@@ -1,74 +1,82 @@
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}
@@ -77,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>
)}
@@ -113,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>
)
);
}
@@ -16,13 +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 { buildSettings, buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } 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 BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
+59 -56
View File
@@ -1,38 +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 ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
}
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
};
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: { 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 }] : []
...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)',
@@ -46,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'
}`
}
@@ -57,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}>
@@ -92,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';
+226 -97
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',
@@ -218,7 +238,8 @@ const texts: Record<string, DemoTexts> = {
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.',
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.',
@@ -241,89 +262,138 @@ const texts: Record<string, DemoTexts> = {
['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.',
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: 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))',
<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',
display: 'flex', flexDirection: 'column',
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
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>
@@ -331,69 +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={{
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={{
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 }],
+322 -185
View File
@@ -1,168 +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 [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)
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(() => {
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 })
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)
}
}, [])
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)
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled);
useEffect(() => {
if (user) loadAddons()
}, [user, location.pathname])
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 themeTransitionTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (themeTransitionTimer.current !== null) {
window.clearTimeout(themeTransitionTimer.current);
themeTransitionTimer.current = null;
}
},
[]
);
const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
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)
}
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
? (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="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="trek-back-btn 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="trek-back-icon 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>
</>
@@ -174,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>
)}
@@ -187,117 +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 relative w-8 h-8 items-center justify-center"
<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'}>
<Sun className="w-4 h-4 absolute 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="w-4 h-4 absolute 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)' }} />
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="trek-menu-enter 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/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>
</div>
)}
)}
</div>
</div>
</div>
</>,
document.body
)}
</>,
document.body
)}
</div>
)}
</nav>
)
);
}
+32 -32
View File
@@ -11,50 +11,53 @@
* 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} queued`
: 'Offline'
: `Syncing ${pendingCount}`
: `Syncing ${pendingCount}`;
return (
<div
@@ -82,11 +85,8 @@ export default function OfflineBanner(): React.ReactElement | null {
pointerEvents: 'none',
}}
>
{offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
{offline ? <WifiOff size={12} /> : <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />}
{label}
</div>
)
);
}
+49 -50
View File
@@ -1,21 +1,21 @@
import React, { useState, useEffect, useRef } from 'react'
import { Menu, X, type LucideIcon } from 'lucide-react'
import { Menu, X, type LucideIcon } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
export interface PageSidebarTab {
id: string
label: string
icon: LucideIcon
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
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
footer?: React.ReactNode;
}
/**
@@ -33,21 +33,23 @@ export default function PageSidebar({
children,
footer,
}: PageSidebarProps): React.ReactElement {
const [mobileOpen, setMobileOpen] = useState(false)
const activeLabel = tabs.find(t => t.id === activeTab)?.label ?? ''
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)
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])
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="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative"
className="relative flex flex-col overflow-hidden rounded-2xl lg:flex-row"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
@@ -56,12 +58,12 @@ export default function PageSidebar({
>
{/* Mobile top bar with hamburger */}
<div
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
className="flex items-center justify-between border-b px-4 py-3 lg:hidden"
style={{ borderColor: 'var(--border-primary)' }}
>
<button
onClick={() => setMobileOpen(true)}
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
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)' }}
>
@@ -75,7 +77,7 @@ export default function PageSidebar({
{/* Desktop sidebar (always visible on lg) */}
<aside
className="hidden lg:flex flex-col shrink-0 relative"
className="relative hidden shrink-0 flex-col lg:flex"
style={{
width: 260,
background: 'var(--bg-secondary)',
@@ -96,29 +98,26 @@ export default function PageSidebar({
{mobileOpen && (
<>
<div
className="lg:hidden fixed inset-0 z-40"
className="fixed inset-0 z-40 lg:hidden"
style={{ background: 'rgba(0,0,0,0.35)' }}
onClick={() => setMobileOpen(false)}
/>
<aside
ref={drawerRef}
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl"
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="flex items-center justify-between mb-3 px-2">
<span
className="text-[11px] font-bold tracking-widest uppercase"
style={{ color: 'var(--text-muted)' }}
>
<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="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
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)' }}
>
@@ -130,8 +129,8 @@ export default function PageSidebar({
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
onTabChange(id)
setMobileOpen(false)
onTabChange(id);
setMobileOpen(false);
}}
footer={footer}
/>
@@ -140,11 +139,11 @@ export default function PageSidebar({
)}
{/* Panel */}
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
<div className="min-w-0 flex-1" style={{ padding: '26px 28px' }}>
{children}
</div>
</div>
)
);
}
function SidebarInner({
@@ -154,57 +153,57 @@ function SidebarInner({
onTabChange,
footer,
}: {
sidebarLabel: string | null
tabs: PageSidebarTab[]
activeTab: string
onTabChange: (id: string) => void
footer?: React.ReactNode
sidebarLabel: string | null;
tabs: PageSidebarTab[];
activeTab: string;
onTabChange: (id: string) => void;
footer?: React.ReactNode;
}): React.ReactElement {
return (
<>
{sidebarLabel && (
<div
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
className="mb-3 px-3 text-[11px] font-bold uppercase tracking-widest"
style={{ color: 'var(--text-muted)' }}
>
{sidebarLabel}
</div>
)}
<nav className="flex flex-col gap-1 flex-1">
<nav className="flex flex-1 flex-col gap-1">
{tabs.map((tab) => {
const Icon = tab.icon
const active = tab.id === activeTab
const Icon = tab.icon;
const active = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
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)'
if (!active) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent'
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 pt-3 px-3 text-[10px] tracking-wide"
className="mt-4 px-3 pt-3 text-[10px] tracking-wide"
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
>
{footer}
</div>
)}
</>
)
);
}
+11 -11
View File
@@ -1,13 +1,13 @@
import { Navigation, LocateFixed, Locate } from 'lucide-react'
import type { TrackingMode } from '../../hooks/useGeolocation'
import { Locate, LocateFixed, Navigation } from 'lucide-react';
import type { TrackingMode } from '../../hooks/useGeolocation';
interface Props {
mode: TrackingMode
error: string | null
onClick: () => void
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
bottomOffset?: number;
}
// Three-state FAB. Matches the Apple/Google Maps pattern:
@@ -15,15 +15,15 @@ interface Props {
// 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 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'
: 'Stop following';
return (
<button
@@ -42,7 +42,7 @@ export default function LocationButton({ mode, error, onClick, bottomOffset = 20
border: 'none',
cursor: 'pointer',
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
color: isActive ? 'white' : error ? '#ef4444' : 'var(--text-muted, #6b7280)',
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
display: 'flex',
alignItems: 'center',
@@ -52,5 +52,5 @@ export default function LocationButton({ mode, error, onClick, bottomOffset = 20
>
<Icon size={20} strokeWidth={mode === 'follow' ? 2.5 : 2} />
</button>
)
);
}
+123 -127
View File
@@ -1,11 +1,10 @@
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
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(),
@@ -15,18 +14,13 @@ const mapMock = vi.hoisted(() => ({
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 } })}
@@ -39,11 +33,11 @@ vi.mock('react-leaflet', () => ({
Circle: () => <div data-testid="circle" />,
useMap: () => mapMock,
useMapEvents: () => ({}),
}))
}));
vi.mock('react-leaflet-cluster', () => ({
default: ({ children }: any) => <div data-testid="cluster-group">{children}</div>,
}))
}));
vi.mock('leaflet', () => ({
default: {
@@ -56,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),
@@ -64,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.
@@ -77,175 +71,177 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
category_color: null,
category_icon: null,
...overrides,
} as any
} as any;
}
afterEach(() => {
vi.clearAllMocks()
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', 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')
})
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', async () => {
const user = userEvent.setup()
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} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
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', async () => {
const user = userEvent.setup()
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} />)
await user.click(screen.getByTestId('marker-hover-trigger'))
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()
})
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: 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
];
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)
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)
})
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
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)
})
})
rerender(<MapView places={places} fitKey={2} />);
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst);
});
});
+398 -340
View File
@@ -1,59 +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, 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 ReservationOverlay from './ReservationOverlay'
import type { Reservation } from '../../types'
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;
@@ -65,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>`;
}
// 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/'))) {
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="
@@ -90,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({
@@ -112,128 +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]) // eslint-disable-line react-hooks/exhaustive-deps
}, [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) {
if (!midpoint) return null
if (!midpoint) return null;
const icon = L.divIcon({
className: 'route-info-pill',
@@ -259,50 +264,64 @@ 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 { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
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 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'
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
const map = useMap()
const map = useMap();
// 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 (mode !== 'follow' || !position) return
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ }
}, [position, mode, map])
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, mode, map]);
// 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)
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 (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
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="
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);
@@ -311,7 +330,7 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null
mask:radial-gradient(circle, transparent 12px, black 13px);
pointer-events:none;
"></div>`,
})
});
return (
<>
@@ -324,12 +343,7 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null
/>
)}
{headingIcon && (
<Marker
position={[position.lat, position.lng]}
icon={headingIcon}
interactive={false}
zIndexOffset={900}
/>
<Marker position={[position.lat, position.lng]} icon={headingIcon} interactive={false} zIndexOffset={900} />
)}
<CircleMarker
center={[position.lat, position.lng]}
@@ -338,23 +352,29 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null
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
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,
place,
isSelected,
orderNumbers,
photoUrl,
onClickPlace,
onHover,
onHoverOut,
}: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected);
return (
<Marker
position={[place.lat, place.lng]}
@@ -367,8 +387,8 @@ const MemoMarker = memo(function MemoMarker({
}}
zIndexOffset={isSelected ? 1000 : 0}
/>
)
})
);
});
export const MapView = memo(function MapView({
places = [],
@@ -394,265 +414,303 @@ export const MapView = memo(function MapView({
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])
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 [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 })
}, [])
setHoveredPlace(place);
setTooltipPos({ x, y });
}, []);
const handleMarkerHoverOut = useCallback(() => {
setHoveredPlace(null)
}, [])
setHoveredPlace(null);
}, []);
const handleMarkerClick = useCallback((id: number) => {
onMarkerClick?.(id)
}, [onMarkerClick])
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 placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
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)
const pendingThumbsRef = useRef<Record<string, string>>({});
const thumbRafRef = useRef<number | null>(null);
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 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
if (!places || places.length === 0 || !placesPhotosEnabled) return;
const cleanups: (() => void)[] = [];
const setThumb = (cacheKey: string, thumb: string) => {
pendingThumbsRef.current[cacheKey] = thumb
if (thumbRafRef.current !== null) return
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
})
})
}
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) {
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;
}
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
cleanups.push(onThumbReady(cacheKey, (thumb) => setThumb(cacheKey, thumb)));
if (!cached && !isLoading(cacheKey)) {
const photoId =
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
(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())
cleanups.forEach((fn) => fn());
if (thumbRafRef.current !== null) {
cancelAnimationFrame(thumbRafRef.current)
thumbRafRef.current = null
cancelAnimationFrame(thumbRafRef.current);
thumbRafRef.current = null;
}
}
}, [placeIds, placesPhotosEnabled])
};
}, [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' && 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 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}
/>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
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}
/>
);
}),
[places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut]
);
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 []
return [(
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)]
} catch { return [] }
}), [places])
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 [];
return [
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>,
];
} catch {
return [];
}
}),
[places]
);
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice;
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null;
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
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)'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)';
return (
<>
<div className="w-full h-full relative">
<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"
/>
<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} />
<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>
<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} />
))}
</>
)}
{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}
{/* 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>
<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>
)}
</>
)
})
);
});
+7 -7
View File
@@ -1,16 +1,16 @@
import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
import { MapViewGL } from './MapViewGL'
import { useSettingsStore } from '../../store/settingsStore';
import { MapView } from './MapView';
import { MapViewGL } from './MapViewGL';
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
const provider = useSettingsStore((s) => s.settings.map_provider);
const token = useSettingsStore((s) => s.settings.mapbox_access_token);
// Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token.
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
return <MapView {...props} />
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />;
return <MapView {...props} />;
}
+46 -59
View File
@@ -1,10 +1,9 @@
import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { act } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import { act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { render } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
// Stable fake map so fitBounds call counts survive re-renders.
const glMap = vi.hoisted(() => ({
@@ -26,7 +25,7 @@ const glMap = vi.hoisted(() => ({
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => document.createElement('div')),
}))
}));
vi.mock('mapbox-gl', () => ({
default: {
@@ -41,8 +40,8 @@ vi.mock('mapbox-gl', () => ({
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
}));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
@@ -50,15 +49,15 @@ vi.mock('./mapboxSetup', () => ({
wantsTerrain: vi.fn(() => false),
addCustom3dBuildings: vi.fn(),
addTerrainAndSky: vi.fn(),
}))
}));
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
}))
}));
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
}))
}));
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => ({
@@ -68,7 +67,7 @@ vi.mock('../../hooks/useGeolocation', () => ({
cycleMode: vi.fn(),
setMode: vi.fn(),
})),
}))
}));
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
@@ -76,9 +75,9 @@ vi.mock('../../services/photoService', () => ({
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
}));
import { MapViewGL } from './MapViewGL'
import { MapViewGL } from './MapViewGL';
function buildMapPlace(overrides: Record<string, any> = {}) {
return {
@@ -87,7 +86,7 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
category_color: null,
category_icon: null,
...overrides,
} as any
} as any;
}
beforeEach(() => {
@@ -99,66 +98,54 @@ beforeEach(() => {
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
mapbox_3d_enabled: false,
},
} as any)
})
} as any);
});
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
vi.clearAllMocks();
resetAllStores();
});
describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
];
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
const { rerender } = render(<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />);
await act(async () => {});
const after_initial = glMap.fitBounds.mock.calls.length;
// Selecting a place flips hasInspector → paddingOpts memo changes.
// fitBounds must NOT fire again (this was the bug).
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
rerender(<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial);
});
it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })];
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
const { rerender } = render(<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />);
await act(async () => {});
const after_initial = glMap.fitBounds.mock.calls.length;
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
rerender(<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial);
});
it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })];
const { rerender } = render(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const after_first = glMap.fitBounds.mock.calls.length
const { rerender } = render(<MapViewGL places={places} fitKey={1} />);
await act(async () => {});
const after_first = glMap.fitBounds.mock.calls.length;
rerender(<MapViewGL places={places} fitKey={2} />)
await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
})
})
rerender(<MapViewGL places={places} fitKey={2} />);
await act(async () => {});
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first);
});
});
+365 -289
View File
@@ -1,75 +1,86 @@
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { useGeolocation } from '../../hooks/useGeolocation';
import { fetchPhoto, getAllThumbs, getCached, isLoading, onThumbReady } from '../../services/photoService';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Place, Reservation } from '../../types';
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons';
import LocationButton from './LocationButton';
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox';
import {
addCustom3dBuildings,
addTerrainAndSky,
isStandardFamily,
supportsCustom3d,
wantsTerrain,
} from './mapboxSetup';
import { ReservationMapboxOverlay } from './reservationsMapbox';
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 '';
}
}
interface RouteSegment {
mid: [number, number]
from: [number, number]
to: [number, number]
walkingText?: string
drivingText?: string
mid: [number, number];
from: [number, number];
to: [number, number];
walkingText?: string;
drivingText?: string;
}
interface Props {
places: Place[]
dayPlaces?: Place[]
route?: [number, number][][] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
onMarkerClick?: (id: number) => void
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
center?: [number, number]
zoom?: number
fitKey?: number | null
dayOrderMap?: Record<number, number[] | null>
leftWidth?: number
rightWidth?: number
hasInspector?: boolean
hasDayDetail?: boolean
reservations?: Reservation[]
visibleConnectionIds?: number[]
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
places: Place[];
dayPlaces?: Place[];
route?: [number, number][][] | null;
routeSegments?: RouteSegment[];
selectedPlaceId?: number | null;
onMarkerClick?: (id: number) => void;
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void;
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null;
center?: [number, number];
zoom?: number;
fitKey?: number | null;
dayOrderMap?: Record<number, number[] | null>;
leftWidth?: number;
rightWidth?: number;
hasInspector?: boolean;
hasDayDetail?: boolean;
reservations?: Reservation[];
visibleConnectionIds?: number[];
showReservationStats?: boolean;
onReservationClick?: (reservationId: number) => void;
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
const size = selected ? 44 : 36
const borderColor = selected ? '#111827' : 'white'
const borderWidth = selected ? 3 : 2.5
const shadow = selected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = place.category_color || '#6b7280'
function createMarkerElement(
place: Place & { category_color?: string; category_icon?: string },
photoUrl: string | null,
orderNumbers: number[] | null,
selected: boolean
): HTMLDivElement {
const size = selected ? 44 : 36;
const borderColor = selected ? '#111827' : 'white';
const borderWidth = selected ? 3 : 2.5;
const shadow = selected ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.22)';
const bgColor = place.category_color || '#6b7280';
// The visual circle is `size` + 2*border on each side. To make the
// mapbox `anchor: 'center'` land on the real visual middle of the marker
// (rather than just the inner content box), the wrapper has to be the
// full outer size. If we gave the wrapper only `size`, the border would
// bleed outside it and the route lines would appear slightly off.
const outer = size + borderWidth * 2
const outer = size + borderWidth * 2;
let badgeHtml = ''
let badgeHtml = '';
if (orderNumbers && orderNumbers.length > 0) {
const label = orderNumbers.join(' · ')
const label = orderNumbers.join(' · ');
badgeHtml = `<span style="
position:absolute;bottom:-2px;right:-2px;
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
@@ -81,10 +92,10 @@ function createMarkerElement(place: Place & { category_color?: string; category_
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>`;
}
const wrap = document.createElement('div')
const wrap = document.createElement('div');
// Do NOT set `position: relative` here — mapbox-gl ships
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
// `position: relative` here overrides the class, turns every marker into
@@ -92,9 +103,9 @@ function createMarkerElement(place: Place & { category_color?: string; category_
// canvas container. The result looks exactly like "markers drift as the
// map zooms" because each marker's transform is then applied relative
// to its stacked slot, not to the map viewport.
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`;
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'));
if (hasPhoto) {
wrap.innerHTML = `
<div style="
@@ -108,7 +119,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
<img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
</div>
${badgeHtml}
`
`;
} else {
wrap.innerHTML = `
<div style="
@@ -123,15 +134,16 @@ function createMarkerElement(place: Place & { category_color?: string; category_
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
</div>
${badgeHtml}
`
`;
}
return wrap
return wrap;
}
export function MapViewGL({
places = [],
dayPlaces = [],
route = null,
routeSegments = [],
selectedPlaceId = null,
onMarkerClick,
onMapClick,
@@ -149,33 +161,40 @@ export function MapViewGL({
showReservationStats = false,
onReservationClick,
}: Props) {
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 showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
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 showEndpointLabels = useSettingsStore((s) => s.settings.map_booking_labels) !== false;
const placesPhotosEnabled = useAuthStore((s) => s.placesPhotosEnabled);
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs);
const [mapReady, setMapReady] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<mapboxgl.Map | null>(null);
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map());
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null);
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null);
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([]);
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
onClickRefs.current.map = onMapClick
onClickRefs.current.context = onMapContextMenu
const onReservationClickRef = useRef(onReservationClick);
onReservationClickRef.current = onReservationClick;
const {
position: userPosition,
mode: trackingMode,
error: trackingError,
cycleMode: cycleTrackingMode,
setMode: setTrackingMode,
} = useGeolocation();
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu });
onClickRefs.current.marker = onMarkerClick;
onClickRefs.current.map = onMapClick;
onClickRefs.current.context = onMapContextMenu;
// Build/rebuild the map on style/token/3d change
useEffect(() => {
if (!containerRef.current || !mapboxToken) return
mapboxgl.accessToken = mapboxToken
if (!containerRef.current || !mapboxToken) return;
mapboxgl.accessToken = mapboxToken;
const map = new mapboxgl.Map({
container: containerRef.current,
@@ -186,20 +205,20 @@ export function MapViewGL({
attributionControl: true,
antialias: mapboxQuality,
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
});
mapRef.current = map;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).__trek_map = map
(window as any).__trek_map = map;
map.on('load', () => {
if (mapbox3d) {
// Terrain is only valuable on satellite styles — on clean vector
// styles it makes route lines drift off the HTML markers because
// the lines snap to DEM height while markers stay at sea level.
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map);
if (supportsCustom3d(mapboxStyle)) {
const dark = document.documentElement.classList.contains('dark')
addCustom3dBuildings(map, dark)
const dark = document.documentElement.classList.contains('dark');
addCustom3dBuildings(map, dark);
}
}
@@ -211,11 +230,15 @@ export function MapViewGL({
// so flatten it out to keep markers pinned. (Satellite variants
// are left alone — the DEM is what gives them their character.)
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
try { map.setTerrain(null) } catch { /* noop */ }
try {
map.setTerrain(null);
} catch {
/* noop */
}
}
// initial route source — kept around so updates can setData() cheaply
if (!map.getSource('trip-route')) {
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'trip-route-line',
type: 'line',
@@ -227,11 +250,11 @@ export function MapViewGL({
'line-dasharray': [2, 1.5],
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
});
}
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({
id: 'trip-gpx-line',
type: 'line',
@@ -242,46 +265,46 @@ export function MapViewGL({
'line-opacity': 0.75,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
});
}
// Signal that sources/layers are attached so overlay effects can
// safely add their own sources. Style rebuilds reset this via the
// cleanup below.
setMapReady(true)
})
setMapReady(true);
});
map.on('click', (e) => {
const t = e.originalEvent.target as HTMLElement
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
})
const t = e.originalEvent.target as HTMLElement;
if (t.closest('.mapboxgl-marker')) return; // markers handle their own click
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } });
});
// In the mapbox-gl map the right mouse button is reserved for the
// built-in rotate/pitch gesture, so we bind the "add place" action
// to the middle mouse button (button === 1) instead.
const canvas = map.getCanvasContainer()
const canvas = map.getCanvasContainer();
const onAuxDown = (ev: MouseEvent) => {
if (ev.button !== 1) return
ev.preventDefault()
const rect = canvas.getBoundingClientRect()
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
if (ev.button !== 1) return;
ev.preventDefault();
const rect = canvas.getBoundingClientRect();
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top]);
onClickRefs.current.context?.({
latlng: { lat: lngLat.lat, lng: lngLat.lng },
originalEvent: ev,
})
}
});
};
// Also suppress the browser's native auxclick menu on middle-click.
const onAuxClick = (ev: MouseEvent) => {
if (ev.button === 1) ev.preventDefault()
}
canvas.addEventListener('mousedown', onAuxDown)
canvas.addEventListener('auxclick', onAuxClick)
if (ev.button === 1) ev.preventDefault();
};
canvas.addEventListener('mousedown', onAuxDown);
canvas.addEventListener('auxclick', onAuxClick);
// Drop follow mode if the user pans the map manually — matches the
// Apple Maps behaviour where the blue dot stays but the map no longer
// chases it until the user taps the button again.
map.on('dragstart', () => {
setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
})
setTrackingMode((prev) => (prev === 'follow' ? 'show' : prev));
});
// Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
// HTML markers at altitude=0 (sea level) by default, so as soon as the
@@ -293,175 +316,217 @@ export function MapViewGL({
// Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
// project the marker onto the same ground the route line sits on.
// We re-apply this every render because DEM tiles stream in async.
let lastAltUpdate = 0
let lastAltUpdate = 0;
const syncMarkerAltitudes = () => {
const now = performance.now()
if (now - lastAltUpdate < 80) return // ~12Hz is plenty
lastAltUpdate = now
markersRef.current.forEach(marker => {
const ll = marker.getLngLat()
let alt = 0
const now = performance.now();
if (now - lastAltUpdate < 80) return; // ~12Hz is plenty
lastAltUpdate = now;
markersRef.current.forEach((marker) => {
const ll = marker.getLngLat();
let alt = 0;
try {
const e = map.queryTerrainElevation([ll.lng, ll.lat])
if (typeof e === 'number' && Number.isFinite(e)) alt = e
} catch { /* terrain not ready */ }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const curAlt = (ll as any).alt ?? 0
if (Math.abs(curAlt - alt) > 0.25) {
marker.setLngLat([ll.lng, ll.lat, alt])
const e = map.queryTerrainElevation([ll.lng, ll.lat]);
if (typeof e === 'number' && Number.isFinite(e)) alt = e;
} catch {
/* terrain not ready */
}
})
}
map.on('render', syncMarkerAltitudes)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const curAlt = (ll as any).alt ?? 0;
if (Math.abs(curAlt - alt) > 0.25) {
marker.setLngLat([ll.lng, ll.lat, alt]);
}
});
};
map.on('render', syncMarkerAltitudes);
return () => {
canvas.removeEventListener('mousedown', onAuxDown)
canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
canvas.removeEventListener('mousedown', onAuxDown);
canvas.removeEventListener('auxclick', onAuxClick);
markersRef.current.forEach((m) => m.remove());
markersRef.current.clear();
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
reservationOverlayRef.current.destroy();
reservationOverlayRef.current = null;
}
if (locationMarkerRef.current) {
locationMarkerRef.current.destroy()
locationMarkerRef.current = null
locationMarkerRef.current.destroy();
locationMarkerRef.current = null;
}
try { map.remove() } catch { /* noop */ }
mapRef.current = null
setMapReady(false)
}
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
try {
map.remove();
} catch {
/* noop */
}
mapRef.current = null;
setMapReady(false);
};
}, [mapboxStyle, mapboxToken, mapbox3d]); // rebuild on style changes only
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
// simultaneous thumb arrivals into one re-render.
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(null)
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
const pendingThumbsRef = useRef<Record<string, string>>({});
const thumbRafRef = useRef<number | null>(null);
const placeIds = useMemo(() => places.map((p) => p.id).join(','), [places]);
useEffect(() => {
if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
if (!places || places.length === 0 || !placesPhotosEnabled) return;
const cleanups: (() => void)[] = [];
const setThumb = (cacheKey: string, thumb: string) => {
pendingThumbsRef.current[cacheKey] = thumb
if (thumbRafRef.current !== null) return
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
})
})
}
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) {
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
if (!cacheKey) continue
const cached = getCached(cacheKey)
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`;
if (!cacheKey) continue;
const cached = getCached(cacheKey);
if (cached?.thumbDataUrl) {
setThumb(cacheKey, cached.thumbDataUrl)
continue
setThumb(cacheKey, cached.thumbDataUrl);
continue;
}
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
cleanups.push(onThumbReady(cacheKey, (thumb) => setThumb(cacheKey, thumb)));
if (!cached && !isLoading(cacheKey)) {
const photoId =
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
(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())
cleanups.forEach((fn) => fn());
if (thumbRafRef.current !== null) {
cancelAnimationFrame(thumbRafRef.current)
thumbRafRef.current = null
cancelAnimationFrame(thumbRafRef.current);
thumbRafRef.current = null;
}
}
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
};
}, [placeIds, placesPhotosEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
// Reconcile markers with places + photos. Rebuilds the DOM node when any
// visual input changes so photos, selection state and order badges stay
// in sync.
useEffect(() => {
const map = mapRef.current
if (!map) return
const ids = new Set(places.map(p => p.id))
const map = mapRef.current;
if (!map) return;
const ids = new Set(places.map((p) => p.id));
markersRef.current.forEach((marker, id) => {
if (!ids.has(id)) {
marker.remove()
markersRef.current.delete(id)
marker.remove();
markersRef.current.delete(id);
}
})
});
places.forEach(place => {
if (!place.lat || !place.lng) return
const orderNumbers = dayOrderMap[place.id] ?? null
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
const selected = place.id === selectedPlaceId
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
places.forEach((place) => {
if (!place.lat || !place.lng) return;
const orderNumbers = dayOrderMap[place.id] ?? null;
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`;
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null;
const selected = place.id === selectedPlaceId;
const el = createMarkerElement(
place as Place & { category_color?: string; category_icon?: string },
photoUrl,
orderNumbers,
selected
);
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onClickRefs.current.marker?.(place.id)
})
ev.stopPropagation();
onClickRefs.current.marker?.(place.id);
});
// Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id)
if (existing) existing.remove()
const existing = markersRef.current.get(place.id);
if (existing) existing.remove();
// Default (viewport-aligned) anchors keep the marker parallel to the
// screen so its pixel centre lines up with the route line at any
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
// but it rotates the element by the pitch angle and visually offsets
// the anchor by ~100px at 45° tilt, which caused the observed drift.
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([place.lng, place.lat])
.addTo(map)
markersRef.current.set(place.id, m)
})
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([place.lng, place.lat]).addTo(map);
markersRef.current.set(place.id, m);
});
}, [places, selectedPlaceId, dayOrderMap, photoUrls]);
// Update route geojson
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}))
src.setData({ type: 'FeatureCollection', features })
}, [route])
const map = mapRef.current;
if (!map) return;
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined;
if (!src) return;
const features = (route || [])
.filter((seg) => seg && seg.length > 1)
.map((seg) => ({
type: 'Feature' as const,
properties: {},
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}));
src.setData({ type: 'FeatureCollection', features });
}, [route]);
// Travel-time pills between consecutive places. The GL map accepted the
// routeSegments prop but never drew anything, so the labels that Leaflet
// shows were missing here (#850). Render them as HTML markers, matching the
// Leaflet pill styling.
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
routeLabelMarkersRef.current.forEach((m) => m.remove());
routeLabelMarkersRef.current = [];
for (const seg of routeSegments) {
if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue;
const el = document.createElement('div');
el.style.pointerEvents = 'none';
el.innerHTML = `<div style="display:flex;align-items:center;gap:5px;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);color:#fff;border-radius:99px;padding:3px 9px;font-size:9px;font-weight:600;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>${seg.walkingText ?? ''}</span>
<span style="opacity:0.3">|</span>
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>${seg.drivingText ?? ''}</span>
</div>`;
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([seg.mid[1], seg.mid[0]]).addTo(map);
routeLabelMarkersRef.current.push(m);
}
return () => {
routeLabelMarkersRef.current.forEach((m) => m.remove());
routeLabelMarkersRef.current = [];
};
}, [routeSegments, mapReady]);
// Update GPX geometries
useEffect(() => {
const map = mapRef.current
if (!map) return
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = places.flatMap(place => {
if (!place.route_geometry) return []
const map = mapRef.current;
if (!map) return;
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined;
if (!src) return;
const features = 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 []
return [{
type: 'Feature' as const,
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
}]
} catch { return [] }
})
src.setData({ type: 'FeatureCollection', features })
}, [places])
const coords = JSON.parse(place.route_geometry) as [number, number][];
if (!coords || coords.length < 2) return [];
return [
{
type: 'Feature' as const,
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
},
];
} catch {
return [];
}
});
src.setData({ type: 'FeatureCollection', features });
}, [places]);
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
@@ -474,50 +539,50 @@ export function MapViewGL({
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter(r => set.has(r.id))
}, [reservations, visibleConnectionIds])
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [];
const set = new Set(visibleConnectionIds);
return reservations.filter((r) => set.has(r.id));
}, [reservations, visibleConnectionIds]);
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const map = mapRef.current;
if (!map || !mapReady) return;
if (!reservationOverlayRef.current) {
reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
});
}
reservationOverlayRef.current.update(visibleReservations, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
});
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady]);
// Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
const top = 60
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 };
const top = 60;
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60;
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 };
}, [leftWidth, rightWidth, hasInspector, hasDayDetail]);
const prevFitKey = useRef(-1)
const prevFitKey = useRef(-1);
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
const map = mapRef.current
if (!map) return
const target = dayPlaces.length > 0 ? dayPlaces : places
const valid = target.filter(p => p.lat && p.lng)
if (valid.length === 0) return
const bounds = new mapboxgl.LngLatBounds()
valid.forEach(p => bounds.extend([p.lng, p.lat]))
if (fitKey === prevFitKey.current) return;
prevFitKey.current = fitKey;
const map = mapRef.current;
if (!map) return;
const target = dayPlaces.length > 0 ? dayPlaces : places;
const valid = target.filter((p) => p.lat && p.lng);
if (valid.length === 0) return;
const bounds = new mapboxgl.LngLatBounds();
valid.forEach((p) => bounds.extend([p.lng, p.lat]));
const run = () => {
try {
map.fitBounds(bounds, {
@@ -525,52 +590,60 @@ export function MapViewGL({
maxZoom: 15,
pitch: mapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
}
if (map.loaded()) run()
else map.once('load', run)
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
});
} catch {
/* noop */
}
};
if (map.loaded()) run();
else map.once('load', run);
}, [fitKey]); // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
const map = mapRef.current
if (!map || !selectedPlaceId) return
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
if (!target?.lat || !target?.lng) return
const map = mapRef.current;
if (!map || !selectedPlaceId) return;
const target = places.find((p) => p.id === selectedPlaceId) || dayPlaces.find((p) => p.id === selectedPlaceId);
if (!target?.lat || !target?.lng) return;
try {
map.flyTo({
center: [target.lng, target.lat],
zoom: Math.max(map.getZoom(), 14),
pitch: mapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
});
} catch {
/* noop */
}
}, [selectedPlaceId, mapbox3d]); // eslint-disable-line react-hooks/exhaustive-deps
// External center/zoom prop changes — jump without animation
useEffect(() => {
const map = mapRef.current
if (!map) return
try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ }
}, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps
const map = mapRef.current;
if (!map) return;
try {
map.jumpTo({ center: [center[1], center[0]], zoom });
} catch {
/* noop */
}
}, [center[0], center[1]]); // eslint-disable-line react-hooks/exhaustive-deps
// Blue dot rendering + follow-mode camera. Attach the marker lazily the
// first time a fix arrives so the layers sit on top of everything else
// added so far, and destroy it when tracking is turned off.
useEffect(() => {
const map = mapRef.current
if (!map) return
const map = mapRef.current;
if (!map) return;
if (trackingMode === 'off') {
if (locationMarkerRef.current) {
locationMarkerRef.current.update(null)
locationMarkerRef.current.update(null);
}
return
return;
}
if (!userPosition) return
if (!userPosition) return;
const apply = () => {
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
locationMarkerRef.current.update(userPosition)
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map);
locationMarkerRef.current.update(userPosition);
if (trackingMode === 'follow') {
// easeTo is gentler than flyTo for continuous updates
try {
@@ -579,33 +652,36 @@ export function MapViewGL({
bearing: userPosition.heading ?? map.getBearing(),
zoom: Math.max(map.getZoom(), 16),
duration: 350,
})
} catch { /* noop */ }
});
} catch {
/* noop */
}
}
}
if (map.loaded()) apply()
else map.once('load', apply)
}, [userPosition, trackingMode])
};
if (map.loaded()) apply();
else map.once('load', apply);
}, [userPosition, trackingMode]);
if (!mapboxToken) {
return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
<div className="flex h-full w-full 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 />
No Mapbox access token configured.
<br />
<span className="text-xs">Settings Map Mapbox GL</span>
</div>
</div>
)
);
}
// 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 buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)';
return (
<div className="w-full h-full relative">
<div ref={containerRef} className="w-full h-full" />
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
{isMobile && (
<LocationButton
mode={trackingMode}
@@ -615,5 +691,5 @@ export function MapViewGL({
/>
)}
</div>
)
);
}
+321 -264
View File
@@ -1,44 +1,44 @@
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types'
import L from 'leaflet';
import { Car, Plane, Ship, Train } from 'lucide-react';
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useSettingsStore } from '../../store/settingsStore';
import type { Reservation, ReservationEndpoint } from '../../types';
const ENDPOINT_PANE = 'reservation-endpoints'
const AIRPORT_BADGE_HALF_PX = 16
const BADGE_GAP_PX = 5
const ENDPOINT_PANE = 'reservation-endpoints';
const AIRPORT_BADGE_HALF_PX = 16;
const BADGE_GAP_PX = 5;
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
type TransportType = 'flight' | 'train' | 'cruise' | 'car';
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car'];
const TRANSPORT_COLOR = '#3b82f6'
const TRANSPORT_COLOR = '#3b82f6';
const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geodesic: boolean }> = {
flight: { color: TRANSPORT_COLOR, icon: Plane, geodesic: true },
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
}
};
function useEndpointPane() {
const map = useMap()
const map = useMap();
useMemo(() => {
if (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return
if (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return;
if (!map.getPane(ENDPOINT_PANE)) {
const pane = map.createPane(ENDPOINT_PANE)
pane.style.zIndex = '650'
pane.style.pointerEvents = 'auto'
const pane = map.createPane(ENDPOINT_PANE);
pane.style.zIndex = '650';
pane.style.pointerEvents = 'auto';
}
}, [map])
}, [map]);
}
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span>${label}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
const { icon: IconCmp, color } = TYPE_META[type];
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }));
const labelHtml = label ? `<span>${label}</span>` : '';
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26;
return L.divIcon({
className: 'trek-endpoint-marker',
html: `<div style="
@@ -52,123 +52,158 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11],
})
});
}
function toRad(d: number) { return d * Math.PI / 180 }
function toDeg(r: number) { return r * 180 / Math.PI }
function toRad(d: number) {
return (d * Math.PI) / 180;
}
function toDeg(r: number) {
return (r * 180) / Math.PI;
}
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])];
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])];
const d =
2 *
Math.asin(
Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2)
);
if (d === 0) return [a, b];
const pts: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
const f = i / steps;
const A = Math.sin((1 - f) * d) / Math.sin(d);
const B = Math.sin(f * d) / Math.sin(d);
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2);
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2);
const z = A * Math.sin(lat1) + B * Math.sin(lat2);
const lat = Math.atan2(z, Math.sqrt(x * x + y * y));
const lng = Math.atan2(y, x);
pts.push([toDeg(lat), toDeg(lng)]);
}
return pts
return pts;
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
const segments: [number, number][][] = [];
let cur: [number, number][] = [];
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
if (cur.length > 1) segments.push(cur);
cur = [];
}
cur.push(points[i])
cur.push(points[i]);
}
if (cur.length > 1) segments.push(cur)
return segments
if (cur.length > 1) segments.push(cur);
return segments;
}
function cleanName(name: string): string {
return name.replace(/\s*\([^)]*\)/g, '').trim()
return name.replace(/\s*\([^)]*\)/g, '').trim();
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
const R = 6371;
const dLat = toRad(b[0] - a[0]);
const dLng = toRad(b[1] - a[1]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
const [datePart, timePart] = isoLocal.split('T');
const [y, mo, d] = datePart.split('-').map(Number);
const [h, mi] = (timePart || '00:00').split(':').map(Number);
const guess = Date.UTC(y, mo - 1, d, h, mi);
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
timeZone: tz,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = Object.fromEntries(
fmt
.formatToParts(new Date(guess))
.filter((p) => p.type !== 'literal')
.map((p) => [p.type, p.value])
);
const asUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) % 24,
Number(parts.minute),
Number(parts.second)
);
return guess - (asUtc - guess);
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
function computeDuration(
from: ReservationEndpoint,
to: ReservationEndpoint,
fallbackStart: string | null,
fallbackEnd: string | null
): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart;
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd;
if (!start || !end) return null;
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`;
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`;
if (!start.includes('T') || !end.includes('T')) return null;
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
const fromTz = from.timezone || to.timezone;
const toTz = to.timezone || fromTz;
let startMs: number, endMs: number
let startMs: number, endMs: number;
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
startMs = parseInTz(start, fromTz);
endMs = parseInTz(end, toTz);
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
startMs = new Date(start).getTime();
endMs = new Date(end).getTime();
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
if (endMs <= startMs) endMs += 24 * 60 * 60000;
const minutes = Math.round((endMs - startMs) / 60000);
if (minutes <= 0 || minutes > 48 * 60) return null;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
fallback: [number, number]
mainLabel: string | null
subLabel: string | null
res: Reservation;
from: ReservationEndpoint;
to: ReservationEndpoint;
type: TransportType;
arcs: [number, number][][];
primaryArc: [number, number][];
fallback: [number, number];
mainLabel: string | null;
subLabel: string | null;
}
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
function buildStatsHtml(
color: string,
mainLabel: string | null,
subLabel: string | null
): { html: string; width: number; height: number } {
const estWidth = Math.max(mainLabel ? mainLabel.length * 6.5 : 0, subLabel ? subLabel.length * 5.5 : 0) + 22;
const hasBoth = !!mainLabel && !!subLabel;
const height = hasBoth ? 36 : 22;
const main = mainLabel
? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>`
: '';
const sub = subLabel
? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>`
: '';
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
@@ -180,123 +215,127 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
white-space:nowrap;box-sizing:border-box;
transform-origin:center;
will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
">${main}${sub}</div>`;
return { html, width: estWidth, height };
}
function StatsLabel({ item }: { item: TransportItem }) {
const map = useMap()
const markerRef = useRef<L.Marker | null>(null)
const innerRef = useRef<HTMLElement | null>(null)
const map = useMap();
const markerRef = useRef<L.Marker | null>(null);
const innerRef = useRef<HTMLElement | null>(null);
const arc = item.primaryArc
const color = TYPE_META[item.type].color
const arc = item.primaryArc;
const color = TYPE_META[item.type].color;
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
const { html, width, height } = useMemo(
() => buildStatsHtml(color, item.mainLabel, item.subLabel),
[color, item.mainLabel, item.subLabel]
);
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX;
const compute = () => {
if (arc.length < 2) return null
const size = map.getSize()
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
const cum: number[] = [0]
let total = 0
if (arc.length < 2) return null;
const size = map.getSize();
const pts = arc.map((p) => map.latLngToContainerPoint(p as L.LatLngTuple));
const cum: number[] = [0];
let total = 0;
for (let i = 1; i < pts.length; i++) {
total += pts[i].distanceTo(pts[i - 1])
cum.push(total)
total += pts[i].distanceTo(pts[i - 1]);
cum.push(total);
}
if (total <= 0) return null
if (total <= 0) return null;
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]);
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]);
const isIn = (p: L.Point) => {
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
if (p.distanceTo(fromPx) < buffer) return false
if (p.distanceTo(toPx) < buffer) return false
return true
}
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false;
if (p.distanceTo(fromPx) < buffer) return false;
if (p.distanceTo(toPx) < buffer) return false;
return true;
};
let firstIdx = -1
let lastIdx = -1
let firstIdx = -1;
let lastIdx = -1;
for (let i = 0; i < pts.length; i++) {
if (isIn(pts[i])) {
if (firstIdx < 0) firstIdx = i
lastIdx = i
if (firstIdx < 0) firstIdx = i;
lastIdx = i;
}
}
if (firstIdx < 0) {
const target = total / 2
let sIdx = 0
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
const span = cum[sIdx + 1] - cum[sIdx]
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
const pA = pts[sIdx]
const pB = pts[sIdx + 1]
const mx = pA.x + (pB.x - pA.x) * tm
const my = pA.y + (pB.y - pA.y) * tm
const latlng = map.containerPointToLatLng([mx, my])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
const target = total / 2;
let sIdx = 0;
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++;
const span = cum[sIdx + 1] - cum[sIdx];
const tm = span > 0 ? (target - cum[sIdx]) / span : 0;
const pA = pts[sIdx];
const pB = pts[sIdx + 1];
const mx = pA.x + (pB.x - pA.x) * tm;
const my = pA.y + (pB.y - pA.y) * tm;
const latlng = map.containerPointToLatLng([mx, my]);
let angle = (Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180) / Math.PI;
if (angle > 90) angle -= 180;
if (angle < -90) angle += 180;
return { point: [latlng.lat, latlng.lng] as [number, number], angle };
}
const bisectFraction = (a: L.Point, b: L.Point) => {
let lo = 0, hi = 1
let lo = 0,
hi = 1;
for (let k = 0; k < 10; k++) {
const mid = (lo + hi) / 2
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
if (isIn(mp)) hi = mid
else lo = mid
const mid = (lo + hi) / 2;
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid);
if (isIn(mp)) hi = mid;
else lo = mid;
}
return (lo + hi) / 2
}
return (lo + hi) / 2;
};
let lowCum = cum[firstIdx]
let lowCum = cum[firstIdx];
if (firstIdx > 0) {
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx]);
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t;
}
let highCum = cum[lastIdx]
let highCum = cum[lastIdx];
if (lastIdx < pts.length - 1) {
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx]);
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t);
}
const targetLen = (lowCum + highCum) / 2
const targetLen = (lowCum + highCum) / 2;
let segIdx = 0
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
const segSpan = cum[segIdx + 1] - cum[segIdx]
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
const pA = pts[segIdx]
const pB = pts[segIdx + 1]
const px = pA.x + (pB.x - pA.x) * t
const py = pA.y + (pB.y - pA.y) * t
const latlng = map.containerPointToLatLng([px, py])
let segIdx = 0;
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++;
const segSpan = cum[segIdx + 1] - cum[segIdx];
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0;
const pA = pts[segIdx];
const pB = pts[segIdx + 1];
const px = pA.x + (pB.x - pA.x) * t;
const py = pA.y + (pB.y - pA.y) * t;
const latlng = map.containerPointToLatLng([px, py]);
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
let angle = (Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180) / Math.PI;
if (angle > 90) angle -= 180;
if (angle < -90) angle += 180;
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
return { point: [latlng.lat, latlng.lng] as [number, number], angle };
};
const apply = () => {
const pose = compute()
const marker = markerRef.current
if (!marker) return
const el = marker.getElement() as HTMLElement | null
const pose = compute();
const marker = markerRef.current;
if (!marker) return;
const el = marker.getElement() as HTMLElement | null;
if (!pose) {
if (el) el.style.display = 'none'
return
if (el) el.style.display = 'none';
return;
}
if (el) el.style.display = ''
marker.setLatLng(pose.point as L.LatLngTuple)
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
}
if (el) el.style.display = '';
marker.setLatLng(pose.point as L.LatLngTuple);
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null;
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`;
};
useEffect(() => {
const icon = L.divIcon({
@@ -304,117 +343,128 @@ function StatsLabel({ item }: { item: TransportItem }) {
html,
iconSize: [width, height],
iconAnchor: [width / 2, height / 2],
})
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
marker.addTo(map)
markerRef.current = marker
innerRef.current = null
apply()
});
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false });
marker.addTo(map);
markerRef.current = marker;
innerRef.current = null;
apply();
return () => {
marker.remove()
markerRef.current = null
innerRef.current = null
}
}, [map, html, width, height])
marker.remove();
markerRef.current = null;
innerRef.current = null;
};
}, [map, html, width, height]);
useMapEvents({
move: apply,
zoom: apply,
viewreset: apply,
resize: apply,
})
});
return null
return null;
}
interface Props {
reservations: Reservation[]
showConnections: boolean
showStats: boolean
onEndpointClick?: (reservationId: number) => void
reservations: Reservation[];
showConnections: boolean;
showStats: boolean;
onEndpointClick?: (reservationId: number) => void;
}
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
useEndpointPane()
const map = useMap()
const [zoom, setZoom] = useState(() => map.getZoom())
useEndpointPane();
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
});
const showEndpointLabels = useSettingsStore((s) => s.settings.map_booking_labels) !== false;
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
const out: TransportItem[] = [];
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue;
const eps = r.endpoints || [];
const from = eps.find((e) => e.role === 'from');
const to = eps.find((e) => e.role === 'to');
if (!from || !to) continue;
const type = r.type as TransportType;
const isGeo = TYPE_META[type].geodesic;
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
: [
[
[from.lat, from.lng],
[to.lat, to.lng],
] as [number, number][],
];
const primaryIdx = arcs.reduce((best, seg, idx, all) => (seg.length > all[best].length ? idx : best), 0);
const primaryArc = arcs[primaryIdx] ?? [];
const fallback: [number, number] =
primaryArc.length > 0
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2];
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null);
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`;
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null;
const subParts = [duration, distance].filter(Boolean) as string[];
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null;
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel });
}
return out
}, [reservations])
return out;
}, [reservations]);
const visibleItems = useMemo(() => {
return items.filter(item => {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return fromPx.distanceTo(toPx) >= minPx
})
}, [items, zoom, map])
return items.filter((item) => {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]);
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]);
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200;
return fromPx.distanceTo(toPx) >= minPx;
});
}, [items, zoom, map]);
const labelVisibleIds = useMemo(() => {
const set = new Set<number>()
const set = new Set<number>();
for (const item of visibleItems) {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]);
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]);
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400;
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id);
}
return set
}, [visibleItems, zoom, map])
return set;
}, [visibleItems, zoom, map]);
if (!showConnections) return null
if (!showConnections) return null;
return (
<>
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
pathOptions={{
color: TYPE_META[item.type].color,
weight: 2.5,
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
}}
/>
)))}
{visibleItems.map((item) =>
item.arcs.map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
pathOptions={{
color: TYPE_META[item.type].color,
weight: 2.5,
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
}}
/>
))
)}
{visibleItems.flatMap(item => [
{visibleItems.flatMap((item) => [
<Marker
key={`from-${item.res.id}`}
position={[item.from.lat, item.from.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
icon={endpointIcon(
item.type,
showEndpointLabels && labelVisibleIds.has(item.res.id) ? item.from.code || cleanName(item.from.name) : null
)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
@@ -427,7 +477,10 @@ export default function ReservationOverlay({ reservations, showConnections, show
<Marker
key={`to-${item.res.id}`}
position={[item.to.lat, item.to.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
icon={endpointIcon(
item.type,
showEndpointLabels && labelVisibleIds.has(item.res.id) ? item.to.code || cleanName(item.to.name) : null
)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
@@ -439,9 +492,13 @@ export default function ReservationOverlay({ reservations, showConnections, show
</Marker>,
])}
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
<StatsLabel key={`stats-${item.res.id}`} item={item} />
))}
{showStats &&
visibleItems.map(
(item) =>
item.type === 'flight' &&
(item.mainLabel || item.subLabel) &&
labelVisibleIds.has(item.res.id) && <StatsLabel key={`stats-${item.res.id}`} item={item} />
)}
</>
)
);
}
+6 -4
View File
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
}
// Terrain is only genuinely useful for the satellite imagery styles — on
// clean flat styles like streets/light/dark it nudges route lines onto
// the DEM while our HTML markers stay at Z=0, which causes the visible
// offset when the map is pitched. Restrict terrain to satellite.
// Terrain is only genuinely useful for styles that benefit from elevation
// data. On flat vector styles (streets/light/dark) it nudges route lines
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
// when the map is pitched. Satellite and Outdoors are the intended styles
// for terrain; markers are re-pinned by syncMarkerAltitudes().
export function wantsTerrain(style: string): boolean {
return style === 'mapbox://styles/mapbox/satellite-v9'
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|| style === 'mapbox://styles/mapbox/outdoors-v12'
}
// 3D can be added to every style now — the standard family has it built-in
@@ -1,13 +1,13 @@
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { server } from '../../../tests/helpers/msw/server';
import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore';
import { buildUser } from '../../../tests/helpers/factories';
import MemoriesPanel from './MemoriesPanel';
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
@@ -33,18 +33,10 @@ const immichAddon = {
// Handlers that simulate a connected provider with no photos/links
const connectedHandlers = [
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon] })
),
http.get('/api/integrations/memories/immich/status', () =>
HttpResponse.json({ connected: true })
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
http.get('/api/addons', () => HttpResponse.json({ addons: [immichAddon] })),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
];
beforeEach(() => {
@@ -58,15 +50,11 @@ describe('MemoriesPanel', () => {
// Use a delayed response so loading stays true long enough to assert
server.use(
http.get('/api/addons', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
}),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
@@ -78,12 +66,8 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
@@ -94,7 +78,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
@@ -108,7 +92,7 @@ describe('MemoriesPanel', () => {
},
],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -128,7 +112,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
@@ -144,7 +128,7 @@ describe('MemoriesPanel', () => {
},
],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -156,7 +140,7 @@ describe('MemoriesPanel', () => {
let syncCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
@@ -176,7 +160,7 @@ describe('MemoriesPanel', () => {
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
syncCalled = true;
return HttpResponse.json({ ok: true });
}),
})
);
render(<MemoriesPanel {...defaultProps} />);
@@ -193,7 +177,7 @@ describe('MemoriesPanel', () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
@@ -213,7 +197,7 @@ describe('MemoriesPanel', () => {
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
})
);
render(<MemoriesPanel {...defaultProps} />);
@@ -229,15 +213,31 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
{
photo_id: 1,
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-01T10:00:00Z',
},
{
photo_id: 2,
asset_id: 'photo2',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-10T10:00:00Z',
},
],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -254,9 +254,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
@@ -275,9 +273,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
@@ -298,9 +294,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
@@ -315,7 +309,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
@@ -329,7 +323,7 @@ describe('MemoriesPanel', () => {
},
],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -345,7 +339,7 @@ describe('MemoriesPanel', () => {
let putCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
@@ -363,7 +357,7 @@ describe('MemoriesPanel', () => {
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
putCalled = true;
return HttpResponse.json({ ok: true });
}),
})
);
render(<MemoriesPanel {...defaultProps} />);
@@ -378,7 +372,7 @@ describe('MemoriesPanel', () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
@@ -396,7 +390,7 @@ describe('MemoriesPanel', () => {
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
})
);
render(<MemoriesPanel {...defaultProps} />);
@@ -407,7 +401,7 @@ describe('MemoriesPanel', () => {
// The remove button is the second action button in the hover overlay — no title, just an X icon
// Get all buttons and click the one after the share toggle
const allBtns = screen.getAllByRole('button');
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
const shareIdx = allBtns.findIndex((b) => b.getAttribute('title') === 'Stop sharing');
// The remove button immediately follows the share button in the DOM
await userEvent.click(allBtns[shareIdx + 1]);
@@ -419,11 +413,9 @@ describe('MemoriesPanel', () => {
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
],
assets: [{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' }],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -443,11 +435,9 @@ describe('MemoriesPanel', () => {
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
],
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 }],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -470,15 +460,13 @@ describe('MemoriesPanel', () => {
};
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
),
http.get('/api/addons', () => HttpResponse.json({ addons: [immichAddon, immich2Addon] })),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
@@ -497,15 +485,33 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{
photo_id: 10,
asset_id: 'p1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-01T00:00:00Z',
city: 'Paris',
},
{
photo_id: 11,
asset_id: 'p2',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T00:00:00Z',
city: 'Lyon',
},
],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -522,15 +528,13 @@ describe('MemoriesPanel', () => {
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
assets: [{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }],
})
),
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
addPhotosCalled = true;
return HttpResponse.json({ ok: true });
}),
})
);
render(<MemoriesPanel {...defaultProps} />);
@@ -569,7 +573,7 @@ describe('MemoriesPanel', () => {
http.post('/api/integrations/memories/immich/search', () => {
searchCount++;
return HttpResponse.json({ assets: [] });
}),
})
);
render(<MemoriesPanel {...defaultProps} />);
@@ -589,9 +593,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }))
);
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
@@ -612,17 +614,11 @@ describe('MemoriesPanel', () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({
addons: [
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
],
addons: [{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} }],
})
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
@@ -633,7 +629,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
@@ -650,11 +646,9 @@ describe('MemoriesPanel', () => {
),
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
assets: [{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -672,15 +666,33 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{
photo_id: 10,
asset_id: 'p1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-01T00:00:00Z',
city: 'Paris',
},
{
photo_id: 11,
asset_id: 'p2',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T00:00:00Z',
city: 'Lyon',
},
],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -693,9 +705,9 @@ describe('MemoriesPanel', () => {
expect(select).toHaveValue('Paris');
});
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
it('FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
@@ -711,7 +723,7 @@ describe('MemoriesPanel', () => {
},
],
})
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -731,7 +743,7 @@ describe('MemoriesPanel', () => {
let albumLinked = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
@@ -746,12 +758,23 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
if (!albumLinked) return HttpResponse.json({ links: [] });
return HttpResponse.json({
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
links: [
{
id: 1,
provider: 'immich',
album_id: 'album1',
album_name: 'Summer 2025',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
});
}),
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
HttpResponse.json({ ok: true })
),
)
);
render(<MemoriesPanel {...defaultProps} />);
@@ -769,9 +792,7 @@ describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [] }))
);
render(<MemoriesPanel {...defaultProps} />);
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,11 @@
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { buildSettings, buildUser } from '../../../tests/helpers/factories';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import { useAuthStore } from '../../store/authStore';
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { useSettingsStore } from '../../store/settingsStore';
import InAppNotificationItem from './InAppNotificationItem';
const buildNotification = (overrides = {}) => ({
@@ -102,9 +102,7 @@ describe('InAppNotificationItem', () => {
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
render(
<InAppNotificationItem
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
/>
<InAppNotificationItem notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })} />
);
expect(document.querySelector('img')).toBeInTheDocument();
expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
@@ -173,8 +171,9 @@ describe('InAppNotificationItem', () => {
/>
);
// t('notifications.title') = "Notifications" — the navigate button renders this
const navigateBtn = document.querySelector('button[style*="pointer"]') ??
Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
const navigateBtn =
document.querySelector('button[style*="pointer"]') ??
Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('Notifications'));
expect(navigateBtn).toBeInTheDocument();
});
@@ -196,9 +195,7 @@ describe('InAppNotificationItem', () => {
/>
);
// The navigate button renders t('notifications.title') = "Notifications"
const btn = Array.from(document.querySelectorAll('button')).find(
b => b.textContent?.includes('Notifications')
);
const btn = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('Notifications'));
expect(btn).toBeTruthy();
await user.click(btn!);
expect(markRead).toHaveBeenCalledWith(77);
@@ -1,171 +1,194 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore'
import { useSettingsStore } from '../../store/settingsStore'
import { ArrowRight, Check, CheckCheck, Trash2, User, X } from 'lucide-react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from '../../i18n';
import { InAppNotification, useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { useSettingsStore } from '../../store/settingsStore';
function relativeTime(dateStr: string, locale: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
interface NotificationItemProps {
notification: InAppNotification
onClose?: () => void
notification: InAppNotification;
onClose?: () => void;
}
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = 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 [responding, setResponding] = useState(false)
const { t, locale } = 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 [responding, setResponding] = useState(false);
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore()
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore();
const handleNavigate = async () => {
if (!notification.is_read) await markRead(notification.id)
if (!notification.is_read) await markRead(notification.id);
if (notification.navigate_target) {
navigate(notification.navigate_target)
onClose?.()
navigate(notification.navigate_target);
onClose?.();
}
}
};
const handleRespond = async (response: 'positive' | 'negative') => {
if (responding || notification.response !== null) return
setResponding(true)
await respondToBoolean(notification.id, response)
setResponding(false)
}
if (responding || notification.response !== null) return;
setResponding(true);
await respondToBoolean(notification.id, response);
setResponding(false);
};
const titleText = t(notification.title_key, notification.title_params)
const bodyText = t(notification.text_key, notification.text_params)
const hasUnknownTitle = titleText === notification.title_key
const hasUnknownBody = bodyText === notification.text_key
const titleText = t(notification.title_key, notification.title_params);
const bodyText = t(notification.text_key, notification.text_params);
const hasUnknownTitle = titleText === notification.title_key;
const hasUnknownBody = bodyText === notification.text_key;
return (
<div
className="relative px-4 py-3 transition-colors"
style={{
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
background: notification.is_read ? 'transparent' : dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)',
borderBottom: '1px solid var(--border-secondary)',
}}
>
<div className="flex gap-3 items-start">
<div className="flex items-start gap-3">
{/* Sender avatar */}
<div className="flex-shrink-0 mt-0.5">
<div className="mt-0.5 flex-shrink-0">
{notification.sender_avatar ? (
<img
src={notification.sender_avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
<img src={notification.sender_avatar} alt="" className="h-8 w-8 rounded-full object-cover" />
) : (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
>
{notification.sender_username
? notification.sender_username.charAt(0).toUpperCase()
: <User className="w-4 h-4" />
}
{notification.sender_username ? (
notification.sender_username.charAt(0).toUpperCase()
) : (
<User className="h-4 w-4" />
)}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
<div className="flex flex-shrink-0 items-center gap-0.5">
<span className="mr-1 text-xs" style={{ color: 'var(--text-faint)' }}>
{relativeTime(notification.created_at, locale)}
</span>
{!notification.is_read && (
<button
onClick={() => markRead(notification.id)}
title={t('notifications.markRead')}
className="p-1 rounded transition-colors"
className="rounded p-1 transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-hover)';
e.currentTarget.style.color = 'var(--text-primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--text-faint)';
}}
>
<CheckCheck className="w-3.5 h-3.5" />
<CheckCheck className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
className="p-1 rounded transition-colors"
className="rounded p-1 transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239,68,68,0.1)';
e.currentTarget.style.color = '#ef4444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--text-faint)';
}}
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
<p className="mt-0.5 text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
{hasUnknownBody ? notification.text_key : bodyText}
</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2">
<div className="mt-2 flex gap-2">
<button
onClick={() => handleRespond('positive')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
style={{
background: notification.response === 'positive'
? 'var(--text-primary)'
: notification.response === 'negative'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'positive'
? '#fff'
: notification.response === 'negative'
? 'var(--text-faint)'
: 'var(--text-secondary)',
background:
notification.response === 'positive'
? 'var(--text-primary)'
: notification.response === 'negative'
? dark
? '#27272a'
: '#f1f5f9'
: dark
? '#27272a'
: '#f1f5f9',
color:
notification.response === 'positive'
? '#fff'
: notification.response === 'negative'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'negative' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<Check className="w-3 h-3" />
<Check className="h-3 w-3" />
{t(notification.positive_text_key)}
</button>
<button
onClick={() => handleRespond('negative')}
disabled={responding || notification.response !== null}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
style={{
background: notification.response === 'negative'
? '#ef4444'
: notification.response === 'positive'
? (dark ? '#27272a' : '#f1f5f9')
: (dark ? '#27272a' : '#f1f5f9'),
color: notification.response === 'negative'
? '#fff'
: notification.response === 'positive'
? 'var(--text-faint)'
: 'var(--text-secondary)',
background:
notification.response === 'negative'
? '#ef4444'
: notification.response === 'positive'
? dark
? '#27272a'
: '#f1f5f9'
: dark
? '#27272a'
: '#f1f5f9',
color:
notification.response === 'negative'
? '#fff'
: notification.response === 'positive'
? 'var(--text-faint)'
: 'var(--text-secondary)',
opacity: notification.response === 'positive' ? 0.5 : 1,
cursor: notification.response !== null || responding ? 'default' : 'pointer',
}}
>
<X className="w-3 h-3" />
<X className="h-3 w-3" />
{t(notification.negative_text_key)}
</button>
</div>
@@ -175,17 +198,17 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
<button
onClick={handleNavigate}
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
className="mt-2 flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9')}
>
<ArrowRight className="w-3 h-3" />
<ArrowRight className="h-3 w-3" />
{t(notification.navigate_text_key)}
</button>
)}
</div>
</div>
</div>
)
);
}
@@ -1,6 +1,6 @@
// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import ScopeGroupPicker from './ScopeGroupPicker';
@@ -34,9 +34,7 @@ describe('ScopeGroupPicker', () => {
// First collect all scopes by clicking Select All and capturing the callback
const user = userEvent.setup();
const captured: string[][] = [];
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
const { rerender } = render(<ScopeGroupPicker selected={[]} onChange={(s) => captured.push(s)} />);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
@@ -50,9 +48,7 @@ describe('ScopeGroupPicker', () => {
const captured: string[][] = [];
// Get all scopes first
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
const { rerender } = render(<ScopeGroupPicker selected={[]} onChange={(s) => captured.push(s)} />);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
@@ -67,10 +63,12 @@ describe('ScopeGroupPicker', () => {
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// Groups are collapsed by default — checkboxes for individual scopes not visible
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
const groupToggles = screen
.getAllByRole('button')
.filter(
(b) =>
!b.textContent?.toLowerCase().includes('select all') && !b.textContent?.toLowerCase().includes('deselect all')
);
// Click the first group expand button
await user.click(groupToggles[0]);
// Individual scope checkboxes should now appear (more than just group-level ones)
@@ -96,10 +94,12 @@ describe('ScopeGroupPicker', () => {
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
// Expand first group
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
const groupToggles = screen
.getAllByRole('button')
.filter(
(b) =>
!b.textContent?.toLowerCase().includes('select all') && !b.textContent?.toLowerCase().includes('deselect all')
);
await user.click(groupToggles[0]);
// There are now individual scope checkboxes — click the second one (first is group-level)
@@ -1,64 +1,78 @@
import React, { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { getScopesByGroup } from '../../api/oauthScopes'
import { useTranslation } from '../../i18n'
import { ChevronDown, ChevronRight } from 'lucide-react';
import React, { useState } from 'react';
import { getScopesByGroup } from '../../api/oauthScopes';
import { useTranslation } from '../../i18n';
interface Props {
selected: string[]
onChange: (scopes: string[]) => void
selected: string[];
onChange: (scopes: string[]) => void;
}
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
const { t } = useTranslation()
const [open, setOpen] = useState<Record<string, boolean>>({})
const { t } = useTranslation();
const [open, setOpen] = useState<Record<string, boolean>>({});
const scopesByGroup = getScopesByGroup(t)
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopeKeys.every(s => selected.includes(s))
const scopesByGroup = getScopesByGroup(t);
const allScopeKeys = Object.values(scopesByGroup)
.flat()
.map((s) => s.scope);
const allSelected = allScopeKeys.every((s) => selected.includes(s));
return (
<div className="space-y-1">
<div className="flex justify-end mb-2">
<div className="mb-2 flex justify-end">
<button
type="button"
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
className="rounded border px-2 py-0.5 text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
</button>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
<div className="max-h-96 space-y-1 overflow-y-auto pr-1">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
const groupScopeKeys = groupScopes.map(s => s.scope)
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
const groupScopeKeys = groupScopes.map((s) => s.scope);
const allGroupSelected = groupScopeKeys.every((s) => selected.includes(s));
const someGroupSelected = groupScopeKeys.some((s) => selected.includes(s));
return (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div
key={group}
className="overflow-hidden rounded-lg border"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
<button
type="button"
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
style={{ color: 'var(--text-secondary)' }}>
{open[group]
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
onClick={() => setOpen((prev) => ({ ...prev, [group]: !prev[group] }))}
className="flex flex-1 items-center gap-1 text-left text-xs font-semibold transition-opacity hover:opacity-70"
style={{ color: 'var(--text-secondary)' }}
>
{open[group] ? (
<ChevronDown className="h-3 w-3 flex-shrink-0" />
) : (
<ChevronRight className="h-3 w-3 flex-shrink-0" />
)}
{group}
{someGroupSelected && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
({groupScopeKeys.filter((s) => selected.includes(s)).length}/{groupScopeKeys.length})
</span>
)}
</button>
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
onChange={e => onChange(
e.target.checked
? [...new Set([...selected, ...groupScopeKeys])]
: selected.filter(s => !groupScopeKeys.includes(s))
)}
ref={(el) => {
if (el) el.indeterminate = someGroupSelected && !allGroupSelected;
}}
onChange={(e) =>
onChange(
e.target.checked
? [...new Set([...selected, ...groupScopeKeys])]
: selected.filter((s) => !groupScopeKeys.includes(s))
)
}
className="rounded"
title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
/>
@@ -68,29 +82,32 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
{groupScopes.map(({ scope, label, description }) => (
<label
key={scope}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
className="flex cursor-pointer items-start gap-2.5 px-3 py-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<input
type="checkbox"
checked={selected.includes(scope)}
onChange={e => onChange(
e.target.checked
? [...selected, scope]
: selected.filter(s => s !== scope)
)}
className="mt-0.5 rounded flex-shrink-0"
onChange={(e) =>
onChange(e.target.checked ? [...selected, scope] : selected.filter((s) => s !== scope))
}
className="mt-0.5 flex-shrink-0 rounded"
/>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
{label}
</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{description}
</p>
</div>
</label>
))}
</div>
)}
</div>
)
);
})}
</div>
</div>
)
);
}
@@ -4,7 +4,7 @@
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
// Tests verify the overlay DOM structure and HTML content.
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock `marked` so we don't need the real markdown parser
vi.mock('marked', () => ({
@@ -13,8 +13,8 @@ vi.mock('marked', () => ({
},
}));
import { downloadJourneyBookPDF } from './JourneyBookPDF';
import type { JourneyDetail } from '../../store/journeyStore';
import { downloadJourneyBookPDF } from './JourneyBookPDF';
// ── Helpers ──────────────────────────────────────────────────────────────────
+81 -72
View File
@@ -1,66 +1,66 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { marked } from 'marked'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { marked } from 'marked';
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore';
function esc(str: string | null | undefined): string {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function md(str: string | null | undefined): string {
if (!str) return ''
return marked.parse(str, { async: false, breaks: true }) as string
if (!str) return '';
return marked.parse(str, { async: false, breaks: true }) as string;
}
function abs(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
return window.location.origin + (url.startsWith('/') ? '' : '/') + url;
}
function pSrc(p: JourneyPhoto): string {
return abs(`/api/photos/${p.photo_id}/original`)
return abs(`/api/photos/${p.photo_id}/original`);
}
function fmtDate(d: string): string {
const date = new Date(d + 'T00:00:00')
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const date = new Date(d + 'T00:00:00');
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
function fmtShort(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' });
}
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
const groups = new Map<string, JourneyEntry[]>()
const groups = new Map<string, JourneyEntry[]>();
for (const e of entries) {
if (!e.entry_date) continue
if (!groups.has(e.entry_date)) groups.set(e.entry_date, [])
groups.get(e.entry_date)!.push(e)
if (!e.entry_date) continue;
if (!groups.has(e.entry_date)) groups.set(e.entry_date, []);
groups.get(e.entry_date)!.push(e);
}
return groups
return groups;
}
function renderProscons(entry: JourneyEntry): string {
const pc = entry.pros_cons
if (!pc) return ''
const pros = pc.pros?.filter(p => p.trim()) || []
const cons = pc.cons?.filter(c => c.trim()) || []
if (pros.length === 0 && cons.length === 0) return ''
const pc = entry.pros_cons;
if (!pc) return '';
const pros = pc.pros?.filter((p) => p.trim()) || [];
const cons = pc.cons?.filter((c) => c.trim()) || [];
if (pros.length === 0 && cons.length === 0) return '';
return `<div class="verdict-wrap"><div class="verdict-row">
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map((p) => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map((c) => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`;
}
function renderPhotoBlock(photos: JourneyPhoto[]): string {
if (photos.length === 0) return ''
if (photos.length === 0) return '';
if (photos.length === 1) {
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`;
}
if (photos.length === 2) {
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
return `<div class="entry-photo-duo">${photos.map((p) => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`;
}
// 3+ photos: hero left + stack right
return `<div class="entry-photo-trio">
@@ -69,41 +69,43 @@ function renderPhotoBlock(photos: JourneyPhoto[]): string {
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
</div>
</div>`
</div>`;
}
export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery')
const allPhotos = entries.flatMap(e => e.photos || [])
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
const entries = (journey.entries || []).filter((e) => e.type !== 'skeleton' && e.type !== 'gallery');
const allPhotos = entries.flatMap((e) => e.photos || []);
const coverUrl = journey.cover_image
? abs(`/uploads/${journey.cover_image}`)
: allPhotos[0]
? pSrc(allPhotos[0])
: '';
const grouped = groupByDate(entries)
const dates = [...grouped.keys()].sort()
const grouped = groupByDate(entries);
const dates = [...grouped.keys()].sort();
// Build entry pages — one per entry, day header inline on first entry of day
const entryPages: string[] = []
let pageNum = 1 // cover=1
const entryPages: string[] = [];
let pageNum = 1; // cover=1
dates.forEach((date, di) => {
const dayEntries = grouped.get(date)!
const dayEntries = grouped.get(date)!;
dayEntries.forEach((entry, ei) => {
pageNum++
const isFirstOfDay = ei === 0
const photos = entry.photos || []
const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ')
pageNum++;
const isFirstOfDay = ei === 0;
const photos = entry.photos || [];
const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ');
// Day header (inline, only on first entry of day)
const dayHeaderHtml = isFirstOfDay
? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>`
: ''
const dayHeaderHtml = isFirstOfDay ? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>` : '';
// Photo block
const photoHtml = renderPhotoBlock(photos)
const photoHtml = renderPhotoBlock(photos);
// Pros/cons
const prosconsHtml = renderProscons(entry)
const prosconsHtml = renderProscons(entry);
// Story (markdown)
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : '';
entryPages.push(`
<div class="entry-page">
@@ -116,11 +118,11 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
${prosconsHtml}
</div>
</div>
`)
})
})
`);
});
});
const totalPages = pageNum + 1 // +1 for closing page
const totalPages = pageNum + 1; // +1 for closing page
const html = `<!DOCTYPE html>
<html>
@@ -283,39 +285,46 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
</div>
</body>
</html>`
</html>`;
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
// This avoids window.open() which Safari iOS blocks in async callbacks
// and window.close() which doesn't work reliably in standalone PWA mode.
const overlay = document.createElement('div')
overlay.id = 'journey-pdf-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const overlay = document.createElement('div');
overlay.id = 'journey-pdf-overlay';
overlay.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;';
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
const card = document.createElement('div');
card.style.cssText =
'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);';
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
const header = document.createElement('div');
header.style.cssText =
'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;';
header.innerHTML = `
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} &middot; ${totalPages} pages</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
</div>
`
`;
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
const iframe = document.createElement('iframe');
iframe.style.cssText = 'flex:1;width:100%;border:none;';
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts';
iframe.srcdoc = html;
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
card.appendChild(header);
card.appendChild(iframe);
overlay.appendChild(card);
document.body.appendChild(overlay);
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove();
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => {
iframe.contentWindow?.print();
};
}
+376 -230
View File
@@ -1,224 +1,346 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import {
AlertTriangle,
BedDouble,
Bookmark,
Bus,
Camera,
Car,
Clock,
Coffee,
FileText,
Flag,
Heart,
Hotel,
Info,
KeyRound,
Lightbulb,
LogIn,
LogOut,
LucideIcon,
MapPin,
Navigation,
Plane,
Ship,
ShoppingBag,
Star,
Ticket,
Train,
Users,
Utensils,
} from 'lucide-react';
import { createElement } from 'react';
import { accommodationsApi, mapsApi } from '../../api/client';
import type { AssignmentsMap, Category, Day, DayNotesMap, Place, Trip } from '../../types';
import { getDayOrder, isDayInAccommodationRange } from '../../utils/dayOrder';
import { splitReservationDateTime } from '../../utils/formatters';
import { getCategoryIcon } from '../shared/categoryIcons';
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
return _renderToStaticMarkup(
createElement(icon, props)
);
function renderLucideIcon(icon: LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return '';
return _renderToStaticMarkup(createElement(icon, props));
}
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
const NOTE_ICON_MAP = {
FileText,
Info,
Clock,
MapPin,
Navigation,
Train,
Plane,
Bus,
Car,
Ship,
Coffee,
Ticket,
Star,
Heart,
Camera,
Flag,
Lightbulb,
AlertTriangle,
ShoppingBag,
Bookmark,
};
function noteIconSvg(iconId) {
const Icon = NOTE_ICON_MAP[iconId] || FileText
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
const Icon = NOTE_ICON_MAP[iconId] || FileText;
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' });
}
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
const RESERVATION_ICON_MAP = {
flight: Plane,
train: Train,
bus: Bus,
car: Car,
cruise: Ship,
restaurant: Utensils,
event: Ticket,
tour: Users,
other: FileText,
};
const RESERVATION_COLOR_MAP = {
flight: '#3b82f6',
train: '#06b6d4',
bus: '#6b7280',
car: '#6b7280',
cruise: '#0ea5e9',
restaurant: '#ef4444',
event: '#f59e0b',
tour: '#10b981',
other: '#6b7280',
};
function reservationIconSvg(type) {
const Icon = RESERVATION_ICON_MAP[type] || Ticket
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color })
const Icon = RESERVATION_ICON_MAP[type] || Ticket;
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6';
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color });
}
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
const ACCOMMODATION_ICON_MAP = {
accommodation: Hotel,
checkin: LogIn,
checkout: LogOut,
location: MapPin,
note: FileText,
confirmation: KeyRound,
};
function accommodationIconSvg(type) {
const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' })
const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble;
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' });
}
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgClock2= `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`;
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`;
const svgClock2 = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`;
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`;
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`;
function escHtml(str) {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function absUrl(url) {
if (!url) return null
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
return window.location.origin + (url.startsWith('/') ? '' : '/') + url;
}
function safeImg(url) {
if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
if (!url) return null;
if (url.startsWith('https://') || url.startsWith('http://')) return url;
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null;
}
// Generate SVG string from Lucide icon name (for category thumbnails)
let _renderToStaticMarkup = null
let _renderToStaticMarkup = null;
async function ensureRenderer() {
if (!_renderToStaticMarkup) {
const mod = await import('react-dom/server')
_renderToStaticMarkup = mod.renderToStaticMarkup
const mod = await import('react-dom/server');
_renderToStaticMarkup = mod.renderToStaticMarkup;
}
}
function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
if (!_renderToStaticMarkup) return ''
const Icon = getCategoryIcon(iconName)
return _renderToStaticMarkup(
createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' })
)
if (!_renderToStaticMarkup) return '';
const Icon = getCategoryIcon(iconName);
return _renderToStaticMarkup(createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' }));
}
function shortDate(d, locale) {
if (!d) return ''
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
if (!d) return '';
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
function longDateRange(days, locale) {
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
if (!dd.length) return null
const f = new Date(dd[0].date + 'T00:00:00Z')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
const dd = [...days].filter((d) => d.date).sort((a, b) => a.day_number - b.day_number);
if (!dd.length) return null;
const f = new Date(dd[0].date + 'T00:00:00Z');
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z');
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`;
}
function dayCost(assignments, dayId, locale) {
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0);
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null;
}
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments) {
const photoMap = {} // placeId → photoUrl
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const photoMap = {}; // placeId → photoUrl
const allPlaces = Object.values(assignments)
.flatMap((a) => a.map((x) => x.place))
.filter(Boolean);
const unique = [...new Map(allPlaces.map((p) => [p.id, p])).values()];
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id))
const toFetch = unique.filter((p) => !p.image_url && (p.google_place_id || p.osm_id));
await Promise.allSettled(
toFetch.map(async (place) => {
try {
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name);
if (data.photoUrl) photoMap[place.id] = data.photoUrl;
} catch {}
})
)
return photoMap
);
return photoMap;
}
interface downloadTripPDFProps {
trip: Trip
days: Day[]
places: Place[]
assignments: AssignmentsMap
categories: Category[]
dayNotes: DayNotesMap
reservations?: any[]
t: (key: string, params?: Record<string, string | number>) => string
locale: string
trip: Trip;
days: Day[];
places: Place[];
assignments: AssignmentsMap;
categories: Category[];
dayNotes: DayNotesMap;
reservations?: any[];
t: (key: string, params?: Record<string, string | number>) => string;
locale: string;
}
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
await ensureRenderer()
const loc = _locale || undefined
const tr = _t || (k => k)
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
const range = longDateRange(sorted, loc)
const coverImg = safeImg(trip?.cover_image)
export async function downloadTripPDF({
trip,
days,
places,
assignments,
categories,
dayNotes,
reservations = [],
t: _t,
locale: _locale,
}: downloadTripPDFProps) {
await ensureRenderer();
const loc = _locale || undefined;
const tr = _t || ((k) => k);
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number);
const range = longDateRange(sorted, loc);
const coverImg = safeImg(trip?.cover_image);
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
const photoMap = await fetchPlacePhotos(assignments);
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
).size
Object.values(assignments || {})
.flatMap((a) => a.map((x) => x.place?.id))
.filter(Boolean)
).size;
const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
.flatMap((a) => a)
.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0);
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
const pdfGetDayOrder = (d: Day) => d.day_number
const pdfGetDayOrder = (d: Day) => d.day_number;
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (!startId || startId === endId) return 'single'
if (dayId === startId) return 'start'
if (dayId === endId) return 'end'
return 'middle'
}
const startId = r.day_id;
const endId = r.end_day_id ?? startId;
if (!startId || startId === endId) return 'single';
if (dayId === startId) return 'start';
if (dayId === endId) return 'end';
return 'middle';
};
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
const phase = pdfGetSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
const phase = pdfGetSpanPhase(r, dayId);
if (phase === 'end') return r.reservation_end_time || null;
if (phase === 'middle') return null;
return r.reservation_time || null;
};
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
if (phase === 'single') return null
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
if (r.type === 'hotel') return false
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (startId == null) return false
if (endId !== startId) {
const startDay = sorted.find(d => d.id === startId)
const endDay = sorted.find(d => d.id === endId)
const thisDay = sorted.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
}
return startId === dayId
})
if (phase === 'single') return null;
if (r.type === 'flight')
return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`);
if (r.type === 'car')
return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`);
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`);
};
const pdfGetTransportForDay = (dayId: number) =>
(reservations || []).filter((r) => {
if (r.type === 'hotel') return false;
const startId = r.day_id;
const endId = r.end_day_id ?? startId;
if (startId == null) return false;
if (endId !== startId) {
const startDay = sorted.find((d) => d.id === startId);
const endDay = sorted.find((d) => d.id === endId);
const thisDay = sorted.find((d) => d.id === dayId);
if (!startDay || !endDay || !thisDay) return false;
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay);
}
return startId === dayId;
});
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
const daysHtml = sorted
.map((day, di) => {
const assigned = assignments[String(day.id)] || [];
const notes = (dayNotes || []).filter((n) => n.day_id === day.id);
const cost = dayCost(assignments, day.id, loc);
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
const dayReservations = pdfGetTransportForDay(day.id)
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
const dayReservations = pdfGetTransportForDay(day.id).filter(
(r) => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')
);
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayReservations.forEach(r => {
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'reservation', k: pos, data: r })
})
merged.sort((a, b) => a.k - b.k)
const merged = [];
assigned.forEach((a) => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }));
notes.forEach((n) => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }));
dayReservations.forEach((r) => {
const pos =
r.day_positions?.[day.id] ??
r.day_positions?.[String(day.id)] ??
r.day_plan_position ??
(merged.length > 0 ? Math.max(...merged.map((m) => m.k)) + 0.5 : 0.5);
merged.push({ type: 'reservation', k: pos, data: r });
});
merged.sort((a, b) => a.k - b.k);
let pi = 0
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'reservation') {
const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const icon = reservationIconSvg(r.type)
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
let subtitle = ''
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
const locationLine = r.location || meta.location || ''
const phase = pdfGetSpanPhase(r, day.id)
const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id)
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return `
let pi = 0;
const itemsHtml =
merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged
.map((item) => {
if (item.type === 'reservation') {
const r = item.data;
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : r.metadata || {};
const icon = reservationIconSvg(r.type);
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6';
let subtitle = '';
if (r.type === 'flight')
subtitle = [
meta.airline,
meta.flight_number,
meta.departure_airport && meta.arrival_airport
? `${meta.departure_airport}${meta.arrival_airport}`
: '',
]
.filter(Boolean)
.join(' · ');
else if (r.type === 'train')
subtitle = [
meta.train_number,
meta.platform ? `Gl. ${meta.platform}` : '',
meta.seat ? `Seat ${meta.seat}` : '',
]
.filter(Boolean)
.join(' · ');
else if (r.type === 'restaurant')
subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ');
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ');
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ');
const locationLine = r.location || meta.location || '';
const phase = pdfGetSpanPhase(r, day.id);
const spanLabel = pdfGetSpanLabel(r, phase);
const displayTime = pdfGetDisplayTime(r, day.id);
const time = splitReservationDateTime(displayTime).time ?? '';
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`;
return `
<div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span>
@@ -228,12 +350,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div>
</div>`
}
</div>`;
}
if (item.type === 'note') {
const note = item.data
return `
if (item.type === 'note') {
const note = item.data;
return `
<div class="note-card">
<div class="note-line"></div>
<span class="note-icon">${noteIconSvg(note.icon)}</span>
@@ -241,33 +363,37 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
</div>
</div>`
}
</div>`;
}
pi++
const place = item.data.place
if (!place) return ''
const cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1'
pi++;
const place = item.data.place;
if (!place) return '';
const cat = categories.find((c) => c.id === place.category_id);
const color = cat?.color || '#6366f1';
// Image: direct > google photo > fallback icon
const directImg = safeImg(place.image_url)
const googleImg = photoMap[place.id] || null
const img = directImg || googleImg
// Image: direct > google photo > fallback icon
const directImg = safeImg(place.image_url);
const googleImg = photoMap[place.id] || null;
const img = directImg || googleImg;
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
const thumbHtml = img
? `<img class="place-thumb" src="${escHtml(img)}" />`
: `<div class="place-thumb-fallback" style="background:${color}">
const iconSvg = categoryIconSvg(cat?.icon, color, 24);
const thumbHtml = img
? `<img class="place-thumb" src="${escHtml(img)}" />`
: `<div class="place-thumb-fallback" style="background:${color}">
${iconSvg}
</div>`
</div>`;
const chips = [
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '',
].filter(Boolean).join('')
const chips = [
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
place.price && parseFloat(place.price) > 0
? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>`
: '',
]
.filter(Boolean)
.join('');
return `
return `
<div class="place-card">
<div class="place-bar" style="background:${color}"></div>
${thumbHtml}
@@ -282,31 +408,35 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
</div>
</div>`
}).join('')
</div>`;
})
.join('');
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
).sort((a, b) => {
const startA = days.find(d => d.id === a.start_day_id)
const startB = days.find(d => d.id === b.start_day_id)
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
})
const accommodationsForDay = (accommodations.accommodations || [])
.filter((a) => (day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false))
.sort((a, b) => {
const startA = days.find((d) => d.id === a.start_day_id);
const startB = days.find((d) => d.id === b.start_day_id);
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0);
});
const accommodationDetails = accommodationsForDay.map(item => {
const isCheckIn = day.id === item.start_day_id
const isCheckOut = day.id === item.end_day_id
const actionLabel = isCheckIn ? tr('reservations.meta.checkIn')
: isCheckOut ? tr('reservations.meta.checkOut')
: tr('reservations.meta.linkAccommodation')
const actionIcon = isCheckIn ? accommodationIconSvg('checkin')
: isCheckOut ? accommodationIconSvg('checkout')
: accommodationIconSvg('accommodation')
const timeStr = isCheckIn ? (item.check_in || '')
: isCheckOut ? (item.check_out || '')
: ''
const accommodationDetails = accommodationsForDay
.map((item) => {
const isCheckIn = day.id === item.start_day_id;
const isCheckOut = day.id === item.end_day_id;
const actionLabel = isCheckIn
? tr('reservations.meta.checkIn')
: isCheckOut
? tr('reservations.meta.checkOut')
: tr('reservations.meta.linkAccommodation');
const actionIcon = isCheckIn
? accommodationIconSvg('checkin')
: isCheckOut
? accommodationIconSvg('checkout')
: accommodationIconSvg('accommodation');
const timeStr = isCheckIn ? item.check_in || '' : isCheckOut ? item.check_out || '' : '';
return `
return `
<div class="day-accommodation">
<div class="day-accommodation-title accommodation-center-icon">${actionIcon} ${escHtml(actionLabel)}</div>
${timeStr ? `<div class="accommodation-center-icon">${accommodationIconSvg('checkin')} <b>${escHtml(timeStr)}</b></div>` : ''}
@@ -314,16 +444,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${item.place_address ? `<div class="accommodation-center-icon">${accommodationIconSvg('location')} ${escHtml(item.place_address)}</div>` : ''}
${item.notes ? `<div class="accommodation-center-icon">${accommodationIconSvg('note')} ${escHtml(item.notes)}</div>` : ''}
${isCheckIn && item.confirmation ? `<div class="accommodation-center-icon">${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}</div>` : ''}
</div>`
}).join('')
</div>`;
})
.join('');
const accommodationsHtml = accommodationsForDay.length > 0
? `<div class="day-accommodations-overview">
const accommodationsHtml =
accommodationsForDay.length > 0
? `<div class="day-accommodations-overview">
<div class="day-accommodations ${accommodationsForDay.length === 1 ? 'single' : ''}">${accommodationDetails}</div>
</div>`
: ''
: '';
return `
return `
<div class="day-section${di > 0 ? ' page-break' : ''}">
<div class="day-header">
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
@@ -332,8 +464,9 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div>
<div class="day-body">${accommodationsHtml}${itemsHtml}</div>
</div>`
}).join('')
</div>`;
})
.join('');
const html = `<!DOCTYPE html>
<html lang="${loc.split('-')[0]}">
@@ -508,9 +641,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="cover-dim"></div>
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
<div class="cover-body">
${coverImg
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
: `<div class="cover-circle-ph"></div>`}
${
coverImg
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
: `<div class="cover-circle-ph"></div>`
}
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
<div class="cover-title">${escHtml(trip?.title || 'My Trip')}</div>
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
@@ -529,10 +664,14 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<div class="cover-stat-num">${totalAssigned}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div>
${totalCost > 0 ? `<div>
${
totalCost > 0
? `<div>
<div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
</div>` : ''}
</div>`
: ''
}
</div>
</div>
</div>
@@ -540,19 +679,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
<!-- Days -->
${daysHtml}
</body></html>`
</body></html>`;
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const overlay = document.createElement('div');
overlay.id = 'pdf-preview-overlay';
overlay.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;';
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
const card = document.createElement('div');
card.style.cssText =
'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
const header = document.createElement('div');
header.style.cssText =
'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
@@ -561,18 +705,20 @@ ${daysHtml}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`
`;
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
const iframe = document.createElement('iframe');
iframe.style.cssText = 'flex:1;width:100%;border:none;';
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts';
iframe.srcdoc = html;
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
card.appendChild(header);
card.appendChild(iframe);
overlay.appendChild(card);
document.body.appendChild(overlay);
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove()
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove();
header.querySelector('#pdf-print-btn').onclick = () => {
iframe.contentWindow?.print();
};
}
@@ -1,65 +1,72 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { Package } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { adminApi, packingApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useTripStore } from '../../store/tripStore';
import { useToast } from '../shared/Toast';
interface Template {
id: number
name: string
item_count: number
id: number;
name: string;
item_count: number;
}
interface ApplyTemplateButtonProps {
tripId: number
style: React.CSSProperties
className?: string
tripId: number;
style: React.CSSProperties;
className?: string;
}
// Dropdown-Button um ein Packing-Template auf den aktuellen Trip anzuwenden.
// Rendert nichts wenn keine Templates existieren.
export default function ApplyTemplateButton({ tripId, style, className }: ApplyTemplateButtonProps): React.ReactElement | null {
const [templates, setTemplates] = useState<Template[]>([])
const [open, setOpen] = useState(false)
const [applying, setApplying] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const toast = useToast()
const { t } = useTranslation()
export default function ApplyTemplateButton({
tripId,
style,
className,
}: ApplyTemplateButtonProps): React.ReactElement | null {
const [templates, setTemplates] = useState<Template[]>([]);
const [open, setOpen] = useState(false);
const [applying, setApplying] = useState(false);
const dropRef = useRef<HTMLDivElement>(null);
const toast = useToast();
const { t } = useTranslation();
useEffect(() => {
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId])
adminApi
.packingTemplates()
.then((d) => setTemplates(d.templates || []))
.catch(() => {});
}, [tripId]);
useEffect(() => {
if (!open) return
if (!open) return;
const handler = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleApply = async (templateId: number) => {
setApplying(true)
setApplying(true);
try {
const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count }))
setOpen(false)
const data = await packingApi.applyTemplate(tripId, templateId);
useTripStore.setState((s) => ({ packingItems: [...s.packingItems, ...(data.items || [])] }));
toast.success(t('packing.templateApplied', { count: data.count }));
setOpen(false);
} catch {
toast.error(t('packing.templateError'))
toast.error(t('packing.templateError'));
} finally {
setApplying(false)
setApplying(false);
}
}
};
if (templates.length === 0) return null
if (templates.length === 0) return null;
return (
<div ref={dropRef} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(v => !v)}
onClick={() => setOpen((v) => !v)}
disabled={applying}
className={className ?? 'hover:opacity-[0.88]'}
style={style}
@@ -71,21 +78,40 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
<div
className="trek-menu-enter"
style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220,
position: 'absolute',
right: 0,
top: '100%',
marginTop: 6,
zIndex: 50,
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
padding: 4,
minWidth: 220,
transformOrigin: 'top right',
}}
>
{templates.map(tmpl => (
<button key={tmpl.id} onClick={() => handleApply(tmpl.id)}
{templates.map((tmpl) => (
<button
key={tmpl.id}
onClick={() => handleApply(tmpl.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '8px 12px',
borderRadius: 8,
border: 'none',
cursor: 'pointer',
background: 'transparent',
fontFamily: 'inherit',
fontSize: 12,
color: 'var(--text-primary)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Package size={13} style={{ color: 'var(--text-faint)' }} />
<div style={{ flex: 1, textAlign: 'left' }}>
@@ -99,5 +125,5 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
</div>
)}
</div>
)
);
}
@@ -1,31 +1,37 @@
// FE-COMP-PACKING-001 to FE-COMP-PACKING-020
import { vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { vi } from 'vitest';
import { buildPackingItem, 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, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel';
import PackingListPanel, { itemWeight } from './PackingListPanel';
describe('itemWeight (bag total weight calc)', () => {
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
});
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
expect(itemWeight({ weight_grams: 250 })).toBe(250);
});
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
expect(itemWeight({ quantity: 5 })).toBe(0);
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
expect(itemWeight({})).toBe(0);
});
});
beforeEach(() => {
resetAllStores();
// Side-effect APIs PackingListPanel calls on mount
server.use(
http.get('/api/trips/:id/members', () =>
HttpResponse.json({ owner: null, members: [], current_user_id: 1 })
),
http.get('/api/trips/:id/packing/category-assignees', () =>
HttpResponse.json({ assignees: {} })
),
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: false })
),
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] })
),
http.get('/api/trips/:id/members', () => HttpResponse.json({ owner: null, members: [], current_user_id: 1 })),
http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: {} })),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [] }))
);
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
@@ -60,35 +66,26 @@ describe('PackingListPanel', () => {
});
it('FE-COMP-PACKING-005: shows category group headers', () => {
const items = [
buildPackingItem({ name: 'Toothbrush', category: 'Hygiene' }),
];
const items = [buildPackingItem({ name: 'Toothbrush', category: 'Hygiene' })];
render(<PackingListPanel tripId={1} items={items} />);
expect(screen.getByText('Hygiene')).toBeInTheDocument();
});
it('FE-COMP-PACKING-006: shows progress count in subtitle', () => {
const items = [
buildPackingItem({ name: 'Item1', checked: 1 }),
buildPackingItem({ name: 'Item2', checked: 0 }),
];
const items = [buildPackingItem({ name: 'Item1', checked: 1 }), buildPackingItem({ name: 'Item2', checked: 0 })];
render(<PackingListPanel tripId={1} items={items} />);
expect(screen.getByText(/1 of 2 packed/i)).toBeInTheDocument();
});
it('FE-COMP-PACKING-007: shows progress bar for packed items', () => {
const items = [
buildPackingItem({ name: 'Item1', checked: 1 }),
];
const items = [buildPackingItem({ name: 'Item1', checked: 1 })];
render(<PackingListPanel tripId={1} items={items} />);
// 1/1 = 100% packed shows "All packed!"
expect(screen.getByText('All packed!')).toBeInTheDocument();
});
it('FE-COMP-PACKING-008: items without category are grouped under default category', () => {
const items = [
buildPackingItem({ name: 'Sunscreen', category: null }),
];
const items = [buildPackingItem({ name: 'Sunscreen', category: null })];
render(<PackingListPanel tripId={1} items={items} />);
expect(screen.getByText('Sunscreen')).toBeInTheDocument();
// default category is "Other"
@@ -111,7 +108,7 @@ describe('PackingListPanel', () => {
server.use(
http.post('/api/trips/1/packing', async ({ request }) => {
postCalled = true;
const body = await request.json() as Record<string, unknown>;
const body = (await request.json()) as Record<string, unknown>;
const item = buildPackingItem({ name: String(body.name), category: String(body.category) });
return HttpResponse.json({ item });
})
@@ -210,9 +207,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-020: renders empty filter message when filter yields nothing', async () => {
const user = userEvent.setup();
const items = [
buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' }),
];
const items = [buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' })];
render(<PackingListPanel tripId={1} items={items} />);
await user.click(screen.getByText('Done'));
expect(screen.getByText('No items match this filter')).toBeInTheDocument();
@@ -224,7 +219,7 @@ describe('PackingListPanel', () => {
let patchBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/packing/42', async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
patchBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 42, name: 'Sunblock', category: 'Toiletries' }) });
})
);
@@ -251,7 +246,7 @@ describe('PackingListPanel', () => {
let patchBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/packing/50', async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
patchBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 50, checked: 1 }) });
})
);
@@ -298,7 +293,7 @@ describe('PackingListPanel', () => {
let patchBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/packing/70', async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
patchBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 70, quantity: 5 }) });
})
);
@@ -318,7 +313,7 @@ describe('PackingListPanel', () => {
let postBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', async ({ request }) => {
postBody = await request.json() as Record<string, unknown>;
postBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ name: '...', category: 'Valuables' }) });
})
);
@@ -443,11 +438,11 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }],
})
)
);
const items = [buildPackingItem({ name: 'Laptop', category: 'Electronics' })];
@@ -466,7 +461,7 @@ describe('PackingListPanel', () => {
let putBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/packing/90', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
putBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 90, name: 'Shirt', category: 'Apparel' }) });
})
);
@@ -542,11 +537,11 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }],
})
)
);
const items = [buildPackingItem({ name: 'Charger', category: 'Electronics' })];
@@ -571,11 +566,11 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
HttpResponse.json({
bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }],
})
)
);
const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })];
@@ -644,12 +639,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] })
)
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] }))
);
const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })];
const { container } = render(<PackingListPanel tripId={1} items={items} />);
@@ -669,9 +660,7 @@ describe('PackingListPanel', () => {
buildPackingItem({ name: 'Done1', checked: 1, category: 'Test' }),
buildPackingItem({ name: 'Done2', checked: 1, category: 'Test' }),
];
server.use(
http.delete('/api/trips/1/packing/:itemId', () => HttpResponse.json({ success: true }))
);
server.use(http.delete('/api/trips/1/packing/:itemId', () => HttpResponse.json({ success: true })));
// Mock window.confirm to return true
vi.spyOn(window, 'confirm').mockReturnValue(true);
@@ -696,13 +685,11 @@ describe('PackingListPanel', () => {
let savedTemplateName = '';
server.use(
http.post('/api/trips/1/packing/save-as-template', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const body = (await request.json()) as Record<string, unknown>;
savedTemplateName = String(body.name);
return HttpResponse.json({ success: true });
}),
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] })
)
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [] }))
);
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
const { container } = render(<PackingListPanel tripId={1} items={items} />);
@@ -722,11 +709,11 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }],
})
)
);
const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })];
@@ -751,11 +738,11 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }],
})
)
);
const items = [buildPackingItem({ name: 'Jacket', category: 'Clothing' })];
@@ -791,14 +778,10 @@ describe('PackingListPanel', () => {
let putBody: Record<string, unknown> | null = null;
const itemId = 120;
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
http.put(`/api/trips/1/packing/${itemId}`, async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
putBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: itemId }) });
})
);
@@ -847,14 +830,14 @@ describe('PackingListPanel', () => {
const itemId = 130;
let putBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }],
})
),
http.put(`/api/trips/1/packing/${itemId}`, async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
putBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: itemId }) });
})
);
@@ -916,9 +899,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
const itemId = 140;
server.use(
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
),
@@ -989,7 +970,7 @@ describe('PackingListPanel', () => {
let putBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/packing/71', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
putBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 71, quantity: 7 }) });
})
);
@@ -1025,7 +1006,7 @@ describe('PackingListPanel', () => {
let putBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/packing/74', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
putBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 74, category: 'Documents' }) });
})
);
@@ -1053,7 +1034,7 @@ describe('PackingListPanel', () => {
})
),
http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => {
assignBody = await request.json() as Record<string, unknown>;
assignBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ assignees: [{ user_id: 2, username: 'alice', avatar: null }] });
})
);
@@ -1088,7 +1069,7 @@ describe('PackingListPanel', () => {
})
),
http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
putBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ assignees: [] });
})
);
@@ -1137,7 +1118,7 @@ describe('PackingListPanel', () => {
let importBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing/import', async ({ request }) => {
importBody = await request.json() as Record<string, unknown>;
importBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ count: 2 });
})
);
@@ -1166,11 +1147,15 @@ describe('PackingListPanel', () => {
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }],
})
),
http.post('/api/trips/1/packing/bags', async ({ request }) => {
createBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ bag: { id: 10, name: 'Hiking Pack', color: '#ec4899', weight_limit_grams: null, members: [] } });
createBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
bag: { id: 10, name: 'Hiking Pack', color: '#ec4899', weight_limit_grams: null, members: [] },
});
})
);
const items = [buildPackingItem({ name: 'Boots', category: 'Clothing' })];
@@ -1195,7 +1180,9 @@ describe('PackingListPanel', () => {
server.use(
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }],
})
),
http.delete('/api/trips/1/packing/bags/9', () => {
deleteCalled = true;
@@ -1223,11 +1210,15 @@ describe('PackingListPanel', () => {
server.use(
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }],
})
),
http.put('/api/trips/1/packing/bags/11', async ({ request }) => {
updateBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ bag: { id: 11, name: 'Luggage', color: '#10b981', weight_limit_grams: null, members: [] } });
updateBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
bag: { id: 11, name: 'Luggage', color: '#10b981', weight_limit_grams: null, members: [] },
});
})
);
const items = [buildPackingItem({ name: 'Shoes', category: 'Clothing' })];
@@ -1261,7 +1252,9 @@ describe('PackingListPanel', () => {
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }],
})
)
);
const items = [buildPackingItem({ name: 'Camera', category: 'Electronics' })];
@@ -1302,10 +1295,12 @@ describe('PackingListPanel', () => {
),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
HttpResponse.json({
bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }],
})
),
http.put('/api/trips/1/packing/bags/13/members', async ({ request }) => {
membersBody = await request.json() as Record<string, unknown>;
membersBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ members: [{ user_id: 3, username: 'carol', avatar: null }] });
})
);
@@ -1341,12 +1336,12 @@ describe('PackingListPanel', () => {
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
http.post('/api/trips/1/packing/bags', async ({ request }) => {
createBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ bag: { id: 20, name: 'New Bag', color: '#6366f1', weight_limit_grams: null, members: [] } });
createBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
bag: { id: 20, name: 'New Bag', color: '#6366f1', weight_limit_grams: null, members: [] },
});
}),
http.put('/api/trips/1/packing/150', async () =>
HttpResponse.json({ item: buildPackingItem({ id: 150 }) })
)
http.put('/api/trips/1/packing/150', async () => HttpResponse.json({ item: buildPackingItem({ id: 150 }) }))
);
const items = [buildPackingItem({ id: 150, name: 'Sunglasses', category: 'Accessories' })];
const { container } = render(<PackingListPanel tripId={1} items={items} />);

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