Compare commits

...

9 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
528 changed files with 114704 additions and 63371 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"]
+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',
},
},
])
-11086
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -1,5 +1,5 @@
{
"name": "trek-client",
"name": "@trek/client",
"version": "3.0.22",
"private": true,
"type": "module",
@@ -12,9 +12,13 @@
"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",
@@ -35,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": {
@@ -57,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>
)
);
}
+3 -2
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'
@@ -501,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-[9999] 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);
});
});
+351 -306
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,9 +134,9 @@ function createMarkerElement(place: Place & { category_color?: string; category_
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
</div>
${badgeHtml}
`
`;
}
return wrap
return wrap;
}
export function MapViewGL({
@@ -150,34 +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 routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
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,
@@ -188,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);
}
}
@@ -213,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',
@@ -229,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',
@@ -244,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
@@ -295,204 +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 = []
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'
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)
</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])
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,
@@ -505,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, {
@@ -556,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 {
@@ -610,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}
@@ -646,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} />
)}
</>
)
);
}
@@ -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 -231
View File
@@ -1,225 +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 { splitReservationDateTime } from '../../utils/formatters'
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 = splitReservationDateTime(displayTime).time ?? ''
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>
@@ -229,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>
@@ -242,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}
@@ -283,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>` : ''}
@@ -315,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>
@@ -333,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]}">
@@ -509,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>` : ''}
@@ -530,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>
@@ -541,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">
@@ -562,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} />);
File diff suppressed because it is too large Load Diff
+120 -116
View File
@@ -1,9 +1,9 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import PhotoGallery from './PhotoGallery'
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import PhotoGallery from './PhotoGallery';
vi.mock('./PhotoLightbox', () => ({
PhotoLightbox: ({ onClose, onDelete, photos, initialIndex }: any) => (
@@ -12,7 +12,7 @@ vi.mock('./PhotoLightbox', () => ({
<button onClick={() => onDelete(photos[initialIndex]?.id)}>delete-photo</button>
</div>
),
}))
}));
vi.mock('./PhotoUpload', () => ({
PhotoUpload: ({ onClose }: any) => (
@@ -20,12 +20,11 @@ vi.mock('./PhotoUpload', () => ({
<button onClick={onClose}>close-upload</button>
</div>
),
}))
}));
vi.mock('../shared/Modal', () => ({
default: ({ isOpen, children }: any) =>
isOpen ? <div data-testid="modal">{children}</div> : null,
}))
default: ({ isOpen, children }: any) => (isOpen ? <div data-testid="modal">{children}</div> : null),
}));
const buildPhoto = (overrides = {}) => ({
id: 1,
@@ -37,7 +36,7 @@ const buildPhoto = (overrides = {}) => ({
file_size: 102400,
created_at: '2025-01-15T12:00:00Z',
...overrides,
})
});
const defaultProps = {
onUpload: vi.fn().mockResolvedValue(undefined),
@@ -46,170 +45,175 @@ const defaultProps = {
places: [],
days: [],
tripId: 1,
}
};
describe('PhotoGallery', () => {
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
defaultProps.onUpload = vi.fn().mockResolvedValue(undefined)
defaultProps.onDelete = vi.fn().mockResolvedValue(undefined)
defaultProps.onUpdate = vi.fn().mockResolvedValue(undefined)
})
resetAllStores();
vi.clearAllMocks();
defaultProps.onUpload = vi.fn().mockResolvedValue(undefined);
defaultProps.onDelete = vi.fn().mockResolvedValue(undefined);
defaultProps.onUpdate = vi.fn().mockResolvedValue(undefined);
});
it('FE-COMP-PHOTOGALLERY-001: shows photo count in header', () => {
const photos = [buildPhoto(), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const photos = [buildPhoto(), buildPhoto({ id: 2 })];
render(<PhotoGallery {...defaultProps} photos={photos} />);
// The count paragraph renders "2 Fotos" as split text nodes
expect(screen.getByText((content, el) => el?.tagName === 'P' && el.textContent?.trim().startsWith('2'))).toBeInTheDocument()
expect(screen.getAllByText('Fotos').length).toBeGreaterThan(0)
})
expect(
screen.getByText((content, el) => el?.tagName === 'P' && el.textContent?.trim().startsWith('2'))
).toBeInTheDocument();
expect(screen.getAllByText('Fotos').length).toBeGreaterThan(0);
});
it('FE-COMP-PHOTOGALLERY-002: shows empty state when no photos', () => {
render(<PhotoGallery {...defaultProps} photos={[]} />)
render(<PhotoGallery {...defaultProps} photos={[]} />);
// noPhotos key renders some text — check the empty state container is visible
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(0)
const imgs = document.querySelectorAll('img');
expect(imgs).toHaveLength(0);
// The empty-state button should exist
const uploadButtons = screen.getAllByRole('button')
expect(uploadButtons.length).toBeGreaterThan(0)
})
const uploadButtons = screen.getAllByRole('button');
expect(uploadButtons.length).toBeGreaterThan(0);
});
it('FE-COMP-PHOTOGALLERY-003: renders one thumbnail per photo plus one upload tile', () => {
const photos = [buildPhoto(), buildPhoto({ id: 2 }), buildPhoto({ id: 3 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(3)
const photos = [buildPhoto(), buildPhoto({ id: 2 }), buildPhoto({ id: 3 })];
render(<PhotoGallery {...defaultProps} photos={photos} />);
const imgs = document.querySelectorAll('img');
expect(imgs).toHaveLength(3);
// Upload tile button (with Upload icon and "add" text) is present
const buttons = screen.getAllByRole('button')
const buttons = screen.getAllByRole('button');
// At least the upload tile button exists alongside the header upload button
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
expect(buttons.length).toBeGreaterThanOrEqual(2);
});
it('FE-COMP-PHOTOGALLERY-004: clicking thumbnail opens lightbox at correct index', async () => {
const user = userEvent.setup()
const photos = [buildPhoto(), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const user = userEvent.setup();
const photos = [buildPhoto(), buildPhoto({ id: 2 })];
render(<PhotoGallery {...defaultProps} photos={photos} />);
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden')
expect(thumbnails).toHaveLength(2)
await user.click(thumbnails[1] as HTMLElement)
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden');
expect(thumbnails).toHaveLength(2);
await user.click(thumbnails[1] as HTMLElement);
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1')
})
expect(screen.getByTestId('lightbox')).toBeInTheDocument();
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1');
});
it('FE-COMP-PHOTOGALLERY-005: closing lightbox hides it', async () => {
const user = userEvent.setup()
const photos = [buildPhoto()]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const user = userEvent.setup();
const photos = [buildPhoto()];
render(<PhotoGallery {...defaultProps} photos={photos} />);
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnail as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden');
await user.click(thumbnail as HTMLElement);
expect(screen.getByTestId('lightbox')).toBeInTheDocument();
await user.click(screen.getByText('close-lightbox'))
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
await user.click(screen.getByText('close-lightbox'));
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument();
});
it('FE-COMP-PHOTOGALLERY-006: upload button opens upload modal', async () => {
const user = userEvent.setup()
render(<PhotoGallery {...defaultProps} photos={[]} />)
const user = userEvent.setup();
render(<PhotoGallery {...defaultProps} photos={[]} />);
// The header upload button
const uploadButtons = screen.getAllByRole('button')
const uploadButtons = screen.getAllByRole('button');
// First button with Upload icon in header
await user.click(uploadButtons[0])
await user.click(uploadButtons[0]);
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByTestId('photo-upload')).toBeInTheDocument()
})
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('photo-upload')).toBeInTheDocument();
});
it('FE-COMP-PHOTOGALLERY-007: day filter dropdown shows all days as options', () => {
const days = [
{ id: 1, day_number: 1, date: '2025-01-10', trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{
id: 1,
day_number: 1,
date: '2025-01-10',
trip_id: 1,
title: null,
notes: null,
assignments: [],
notes_items: [],
},
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
render(<PhotoGallery {...defaultProps} photos={[]} days={days} />)
];
render(<PhotoGallery {...defaultProps} photos={[]} days={days} />);
const select = screen.getByRole('combobox')
const options = Array.from(select.querySelectorAll('option'))
const select = screen.getByRole('combobox');
const options = Array.from(select.querySelectorAll('option'));
// "All days" + 2 day options
expect(options.length).toBe(3)
})
expect(options.length).toBe(3);
});
it('FE-COMP-PHOTOGALLERY-008: filtering by day hides photos from other days', async () => {
const user = userEvent.setup()
const user = userEvent.setup();
const days = [
{ id: 1, day_number: 1, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
const photos = [
buildPhoto({ id: 1, day_id: 1 }),
buildPhoto({ id: 2, day_id: 2 }),
]
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />)
];
const photos = [buildPhoto({ id: 1, day_id: 1 }), buildPhoto({ id: 2, day_id: 2 })];
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />);
const select = screen.getByRole('combobox')
await user.selectOptions(select, '1')
const select = screen.getByRole('combobox');
await user.selectOptions(select, '1');
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(1)
})
const imgs = document.querySelectorAll('img');
expect(imgs).toHaveLength(1);
});
it('FE-COMP-PHOTOGALLERY-009: reset filter button appears and clears filter', async () => {
const user = userEvent.setup()
const user = userEvent.setup();
const days = [
{ id: 1, day_number: 1, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
const photos = [
buildPhoto({ id: 1, day_id: 1 }),
buildPhoto({ id: 2, day_id: 2 }),
]
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />)
];
const photos = [buildPhoto({ id: 1, day_id: 1 }), buildPhoto({ id: 2, day_id: 2 })];
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />);
const select = screen.getByRole('combobox')
await user.selectOptions(select, '1')
const select = screen.getByRole('combobox');
await user.selectOptions(select, '1');
// Reset button should now be visible
const resetButton = screen.getByRole('button', { name: /reset/i })
expect(resetButton).toBeInTheDocument()
const resetButton = screen.getByRole('button', { name: /reset/i });
expect(resetButton).toBeInTheDocument();
await user.click(resetButton)
await user.click(resetButton);
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(2)
})
const imgs = document.querySelectorAll('img');
expect(imgs).toHaveLength(2);
});
it('FE-COMP-PHOTOGALLERY-010: deleting last photo in lightbox closes lightbox', async () => {
const user = userEvent.setup()
const photos = [buildPhoto({ id: 1 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const user = userEvent.setup();
const photos = [buildPhoto({ id: 1 })];
render(<PhotoGallery {...defaultProps} photos={photos} />);
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnail as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden');
await user.click(thumbnail as HTMLElement);
expect(screen.getByTestId('lightbox')).toBeInTheDocument();
await user.click(screen.getByText('delete-photo'))
await user.click(screen.getByText('delete-photo'));
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument();
});
it('FE-COMP-PHOTOGALLERY-011: deleting a photo adjusts lightbox index when beyond bounds', async () => {
const user = userEvent.setup()
const photos = [buildPhoto({ id: 1 }), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const user = userEvent.setup();
const photos = [buildPhoto({ id: 1 }), buildPhoto({ id: 2 })];
render(<PhotoGallery {...defaultProps} photos={photos} />);
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnails[1] as HTMLElement)
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden');
await user.click(thumbnails[1] as HTMLElement);
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1')
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1');
await user.click(screen.getByText('delete-photo'))
await user.click(screen.getByText('delete-photo'));
// Lightbox should still be open but at index 0
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('0')
})
})
expect(screen.getByTestId('lightbox')).toBeInTheDocument();
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('0');
});
});
+99 -82
View File
@@ -1,55 +1,76 @@
import { useState, useMemo } from 'react'
import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Photo, Place, Day } from '../../types'
import { Camera, Upload } from 'lucide-react';
import { useMemo, useState } from 'react';
import { getLocaleForLanguage, useTranslation } from '../../i18n';
import type { Day, Photo, Place } from '../../types';
import Modal from '../shared/Modal';
import { PhotoLightbox } from './PhotoLightbox';
import { PhotoUpload } from './PhotoUpload';
interface PhotoGalleryProps {
photos: Photo[]
onUpload: (fd: FormData) => Promise<void>
onDelete: (photoId: number) => Promise<void>
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
places: Place[]
days: Day[]
tripId: number
photos: Photo[];
onUpload: (fd: FormData) => Promise<void>;
onDelete: (photoId: number) => Promise<void>;
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>;
places: Place[];
days: Day[];
tripId: number;
}
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
const { t, language } = useTranslation()
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('')
export default function PhotoGallery({
photos,
onUpload,
onDelete,
onUpdate,
places,
days,
tripId,
}: PhotoGalleryProps) {
const { t, language } = useTranslation();
const [lightboxIndex, setLightboxIndex] = useState(null);
const [showUpload, setShowUpload] = useState(false);
const [filterDayId, setFilterDayId] = useState('');
const filteredPhotos = useMemo(() => {
return photos.filter(photo => {
if (filterDayId && String(photo.day_id) !== String(filterDayId)) return false
return true
})
}, [photos, filterDayId])
return photos.filter((photo) => {
if (filterDayId && String(photo.day_id) !== String(filterDayId)) return false;
return true;
});
}, [photos, filterDayId]);
const handlePhotoClick = (photo) => {
const idx = filteredPhotos.findIndex(p => p.id === photo.id)
setLightboxIndex(idx)
}
const idx = filteredPhotos.findIndex((p) => p.id === photo.id);
setLightboxIndex(idx);
};
const handleDelete = async (photoId) => {
await onDelete(photoId)
await onDelete(photoId);
if (lightboxIndex !== null) {
const newPhotos = filteredPhotos.filter(p => p.id !== photoId)
const newPhotos = filteredPhotos.filter((p) => p.id !== photoId);
if (newPhotos.length === 0) {
setLightboxIndex(null)
setLightboxIndex(null);
} else if (lightboxIndex >= newPhotos.length) {
setLightboxIndex(newPhotos.length - 1)
setLightboxIndex(newPhotos.length - 1);
}
}
}
};
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div
className="flex h-full flex-col"
style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}
>
{/* Header */}
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, flexWrap: 'wrap' }}>
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid rgba(0,0,0,0.06)',
display: 'flex',
alignItems: 'center',
gap: 12,
flexShrink: 0,
flexWrap: 'wrap',
}}
>
<div style={{ marginRight: 'auto' }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
@@ -59,31 +80,29 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<select
value={filterDayId}
onChange={e => setFilterDayId(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
onChange={(e) => setFilterDayId(e.target.value)}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">{t('photos.allDays')}</option>
{(days || []).map(day => (
{(days || []).map((day) => (
<option key={day.id} value={day.id}>
{t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
{t('planner.dayN', { n: day.day_number })}
{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
</option>
))}
</select>
{filterDayId && (
<button
onClick={() => setFilterDayId('')}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
<button onClick={() => setFilterDayId('')} className="text-xs text-gray-500 underline hover:text-gray-700">
{t('common.reset')}
</button>
)}
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
className="flex items-center gap-2 whitespace-nowrap rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700"
>
<Upload className="w-4 h-4" />
<Upload className="h-4 w-4" />
{t('common.upload')}
</button>
</div>
@@ -97,16 +116,16 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>{t('photos.uploadHint')}</p>
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
className="flex items-center gap-2 rounded-xl bg-slate-900 px-6 py-3 font-medium text-white hover:bg-slate-700"
style={{ display: 'inline-flex', margin: '0 auto' }}
>
<Upload className="w-4 h-4" />
<Upload className="h-4 w-4" />
{t('common.upload')}
</button>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
{filteredPhotos.map(photo => (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filteredPhotos.map((photo) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
@@ -119,9 +138,9 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
{/* Upload tile */}
<button
onClick={() => setShowUpload(true)}
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-gray-200 text-gray-400 transition-colors hover:border-slate-400 hover:text-slate-700"
>
<Upload className="w-6 h-6" />
<Upload className="h-6 w-6" />
<span className="text-xs">{t('common.add')}</span>
</button>
</div>
@@ -143,75 +162,73 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
)}
{/* Upload Modal */}
<Modal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
title={t('common.upload')}
size="lg"
>
<Modal isOpen={showUpload} onClose={() => setShowUpload(false)} title={t('common.upload')} size="lg">
<PhotoUpload
tripId={tripId}
days={days}
places={places}
onUpload={async (formData) => {
await onUpload(formData)
setShowUpload(false)
await onUpload(formData);
setShowUpload(false);
}}
onClose={() => setShowUpload(false)}
/>
</Modal>
</div>
)
);
}
interface PhotoThumbnailProps {
photo: Photo
days: Day[]
places: Place[]
onClick: () => void
photo: Photo;
days: Day[];
places: Place[];
onClick: () => void;
}
function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
const day = days?.find((d) => d.id === photo.day_id);
const place = places?.find((p) => p.id === photo.place_id);
return (
<div
className="aspect-square rounded-xl overflow-hidden cursor-pointer relative group bg-gray-100"
className="group relative aspect-square cursor-pointer overflow-hidden rounded-xl bg-gray-100"
onClick={onClick}
>
<img
src={photo.url}
alt={photo.caption || photo.original_name}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onError={e => {
(e.target as HTMLImageElement).style.display = 'none'
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement;
if (next) next.style.display = 'flex';
}}
/>
{/* Fallback */}
<div className="hidden absolute inset-0 items-center justify-center text-gray-400 text-2xl">
🖼
</div>
<div className="absolute inset-0 hidden items-center justify-center text-2xl text-gray-400">🖼</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex flex-col justify-end p-2 opacity-0 group-hover:opacity-100">
{photo.caption && (
<p className="text-white text-xs font-medium truncate">{photo.caption}</p>
)}
<div className="absolute inset-0 flex flex-col justify-end bg-black/0 p-2 opacity-0 transition-all duration-200 group-hover:bg-black/40 group-hover:opacity-100">
{photo.caption && <p className="truncate text-xs font-medium text-white">{photo.caption}</p>}
{(day || place) && (
<p className="text-white/70 text-xs truncate">
{day ? `Tag ${day.day_number}` : ''}{day && place ? ' · ' : ''}{place?.name || ''}
<p className="truncate text-xs text-white/70">
{day ? `Tag ${day.day_number}` : ''}
{day && place ? ' · ' : ''}
{place?.name || ''}
</p>
)}
</div>
</div>
)
);
}
function formatDate(dateStr, locale) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
if (!dateStr) return '';
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
@@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { PhotoLightbox } from './PhotoLightbox'
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { PhotoLightbox } from './PhotoLightbox';
const buildPhoto = (overrides = {}) => ({
id: 1,
@@ -14,7 +14,7 @@ const buildPhoto = (overrides = {}) => ({
file_size: 204800,
created_at: '2025-03-10T10:00:00Z',
...overrides,
})
});
const defaultProps = {
photos: [buildPhoto({ id: 1 }), buildPhoto({ id: 2, url: '/uploads/p2.jpg', original_name: 'p2.jpg' })],
@@ -25,170 +25,183 @@ const defaultProps = {
days: [],
places: [],
tripId: 99,
}
};
describe('PhotoLightbox', () => {
let confirmSpy: ReturnType<typeof vi.spyOn>
let confirmSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
})
resetAllStores();
vi.clearAllMocks();
confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
});
afterEach(() => {
confirmSpy.mockRestore()
})
confirmSpy.mockRestore();
});
it('FE-COMP-PHOTOLIGHTBOX-001: renders the current photo', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
const img = screen.getByRole('img', { name: /p1\.jpg/i })
expect(img).toHaveAttribute('src', '/uploads/p1.jpg')
})
render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
const img = screen.getByRole('img', { name: /p1\.jpg/i });
expect(img).toHaveAttribute('src', '/uploads/p1.jpg');
});
it('FE-COMP-PHOTOLIGHTBOX-002: shows photo counter "1 / 2"', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
expect(screen.getByText('1 / 2')).toBeInTheDocument()
})
render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
expect(screen.getByText('1 / 2')).toBeInTheDocument();
});
it('FE-COMP-PHOTOLIGHTBOX-003: next button advances to second photo', async () => {
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
const user = userEvent.setup();
render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
// Find the ChevronRight button — it's the one after the image in the image area
const buttons = screen.getAllByRole('button')
const nextBtn = buttons.find(btn => btn.querySelector('svg') && btn.className.includes('rounded-full') && btn.className.includes('right-4'))
?? buttons.find(btn => btn.className.includes('rounded-full') && !btn.className.includes('left-4'))
const buttons = screen.getAllByRole('button');
const nextBtn =
buttons.find(
(btn) => btn.querySelector('svg') && btn.className.includes('rounded-full') && btn.className.includes('right-4')
) ?? buttons.find((btn) => btn.className.includes('rounded-full') && !btn.className.includes('left-4'));
// Use the button with ChevronRight — at index 0, only next button is shown
// It's within the image area, has class "rounded-full" and no left-4
const imageAreaButtons = buttons.filter(btn => btn.className.includes('rounded-full'))
expect(imageAreaButtons).toHaveLength(1) // only next at index 0
const imageAreaButtons = buttons.filter((btn) => btn.className.includes('rounded-full'));
expect(imageAreaButtons).toHaveLength(1); // only next at index 0
await user.click(imageAreaButtons[0])
await user.click(imageAreaButtons[0]);
expect(screen.getByText('2 / 2')).toBeInTheDocument()
const img = screen.getByRole('img', { name: /p2\.jpg/i })
expect(img).toHaveAttribute('src', '/uploads/p2.jpg')
})
expect(screen.getByText('2 / 2')).toBeInTheDocument();
const img = screen.getByRole('img', { name: /p2\.jpg/i });
expect(img).toHaveAttribute('src', '/uploads/p2.jpg');
});
it('FE-COMP-PHOTOLIGHTBOX-004: prev button not shown at index 0', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
// At index 0 only the next (ChevronRight) rounded-full button appears
const roundedButtons = screen.getAllByRole('button').filter(btn =>
btn.className.includes('rounded-full'),
)
expect(roundedButtons).toHaveLength(1)
const roundedButtons = screen.getAllByRole('button').filter((btn) => btn.className.includes('rounded-full'));
expect(roundedButtons).toHaveLength(1);
// Confirm this single button is the next button (right-4)
expect(roundedButtons[0].className).toContain('right-4')
})
expect(roundedButtons[0].className).toContain('right-4');
});
it('FE-COMP-PHOTOLIGHTBOX-005: ArrowRight keyboard event advances photo', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
expect(screen.getByText('1 / 2')).toBeInTheDocument()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
expect(screen.getByText('1 / 2')).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' })
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('2 / 2')).toBeInTheDocument()
})
expect(screen.getByText('2 / 2')).toBeInTheDocument();
});
it('FE-COMP-PHOTOLIGHTBOX-006: Escape keyboard event calls onClose', () => {
render(<PhotoLightbox {...defaultProps} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(defaultProps.onClose).toHaveBeenCalled()
})
render(<PhotoLightbox {...defaultProps} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('FE-COMP-PHOTOLIGHTBOX-007: clicking backdrop calls onClose', async () => {
const user = userEvent.setup()
const { container } = render(<PhotoLightbox {...defaultProps} />)
const user = userEvent.setup();
const { container } = render(<PhotoLightbox {...defaultProps} />);
// The outer div.fixed has the onClick={onClose}. Click it directly.
const backdrop = container.firstChild as HTMLElement
await user.click(backdrop)
expect(defaultProps.onClose).toHaveBeenCalled()
})
const backdrop = container.firstChild as HTMLElement;
await user.click(backdrop);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('FE-COMP-PHOTOLIGHTBOX-008: delete button triggers confirm and calls onDelete', async () => {
confirmSpy.mockReturnValue(true)
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
confirmSpy.mockReturnValue(true);
const user = userEvent.setup();
render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
// The trash button has title matching delete
const trashBtn = screen.getByTitle(/delete|löschen/i)
await user.click(trashBtn)
const trashBtn = screen.getByTitle(/delete|löschen/i);
await user.click(trashBtn);
expect(confirmSpy).toHaveBeenCalled()
expect(defaultProps.onDelete).toHaveBeenCalledWith(1)
})
expect(confirmSpy).toHaveBeenCalled();
expect(defaultProps.onDelete).toHaveBeenCalledWith(1);
});
it('FE-COMP-PHOTOLIGHTBOX-009: delete cancelled via confirm does not call onDelete', async () => {
confirmSpy.mockReturnValue(false)
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
confirmSpy.mockReturnValue(false);
const user = userEvent.setup();
render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
const trashBtn = screen.getByTitle(/delete|löschen/i)
await user.click(trashBtn)
const trashBtn = screen.getByTitle(/delete|löschen/i);
await user.click(trashBtn);
expect(confirmSpy).toHaveBeenCalled()
expect(defaultProps.onDelete).not.toHaveBeenCalled()
})
expect(confirmSpy).toHaveBeenCalled();
expect(defaultProps.onDelete).not.toHaveBeenCalled();
});
it('FE-COMP-PHOTOLIGHTBOX-010: clicking caption text enters edit mode', async () => {
const user = userEvent.setup()
const user = userEvent.setup();
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, caption: 'Sunset view' })],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
};
render(<PhotoLightbox {...props} initialIndex={0} />);
// Click on the caption paragraph
const captionEl = screen.getByText('Sunset view')
await user.click(captionEl)
const captionEl = screen.getByText('Sunset view');
await user.click(captionEl);
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('Sunset view')
})
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
expect(input).toHaveValue('Sunset view');
});
it('FE-COMP-PHOTOLIGHTBOX-011: saving caption calls onUpdate', async () => {
const user = userEvent.setup()
const user = userEvent.setup();
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, caption: 'Old caption' })],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
};
render(<PhotoLightbox {...props} initialIndex={0} />);
// Enter edit mode
await user.click(screen.getByText('Old caption'))
await user.click(screen.getByText('Old caption'));
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'New caption')
await user.keyboard('{Enter}')
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'New caption');
await user.keyboard('{Enter}');
await waitFor(() => {
expect(defaultProps.onUpdate).toHaveBeenCalledWith(1, { caption: 'New caption' })
})
})
expect(defaultProps.onUpdate).toHaveBeenCalledWith(1, { caption: 'New caption' });
});
});
it('FE-COMP-PHOTOLIGHTBOX-012: thumbnail strip renders for multiple photos', () => {
const { container } = render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
const { container } = render(<PhotoLightbox {...defaultProps} initialIndex={0} />);
// Thumbnail strip has buttons each containing an img with alt=""
// querySelectorAll finds them regardless of ARIA role filtering
const thumbnailImgs = container.querySelectorAll('button img[alt=""]')
expect(thumbnailImgs).toHaveLength(2)
})
const thumbnailImgs = container.querySelectorAll('button img[alt=""]');
expect(thumbnailImgs).toHaveLength(2);
});
it('FE-COMP-PHOTOLIGHTBOX-013: day and place metadata displayed when photo has day/place', () => {
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, day_id: 1, place_id: 1 })],
days: [{ id: 1, day_number: 2, trip_id: 99, date: null, notes: null }],
places: [{ id: 1, name: 'Colosseum', trip_id: 99, lat: null, lng: null, category: null, notes: null, day_id: null, address: null, order_index: 0 }],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
places: [
{
id: 1,
name: 'Colosseum',
trip_id: 99,
lat: null,
lng: null,
category: null,
notes: null,
day_id: null,
address: null,
order_index: 0,
},
],
};
render(<PhotoLightbox {...props} initialIndex={0} />);
expect(screen.getByText(/Tag 2/)).toBeInTheDocument()
expect(screen.getByText(/Colosseum/)).toBeInTheDocument()
})
})
expect(screen.getByText(/Tag 2/)).toBeInTheDocument();
expect(screen.getByText(/Colosseum/)).toBeInTheDocument();
});
});

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