mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bcdfbc34b | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 | |||
| 51b5bd6966 | |||
| 6072b969d6 | |||
| 4ae4e0c676 | |||
| 51ab30f436 |
@@ -2,6 +2,7 @@ node_modules
|
||||
client/node_modules
|
||||
server/node_modules
|
||||
client/dist
|
||||
shared/dist
|
||||
data
|
||||
uploads
|
||||
.git
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main`
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have updated documentation if needed
|
||||
|
||||
@@ -32,6 +32,30 @@ jobs:
|
||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||
if (!hasLabel) continue;
|
||||
|
||||
// Wiki-only PRs are exempt — clear label and skip
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pull.number,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(pull.created_at);
|
||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,33 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Wiki-only PRs are exempt from branch enforcement
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
console.log('All changed files are under wiki/ — skipping enforcement.');
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If the base was fixed, remove the label and let it through
|
||||
if (base !== 'main') {
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Security Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scout:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
exit-code: true
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,10 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
server/dist/
|
||||
shared/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
|
||||
# Generated PWA icons (built from SVG via prebuild)
|
||||
client/public/icons/*.png
|
||||
|
||||
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 |
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
+52
-19
@@ -1,28 +1,60 @@
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
RUN npm ci --workspace=shared
|
||||
COPY shared/ ./shared/
|
||||
RUN npm run build --workspace=shared
|
||||
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS client-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
RUN npm run build --workspace=client
|
||||
|
||||
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY server/ ./server/
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
COPY server/ ./
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
# better-sqlite3 native addon requires build tools; purged after install.
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
apk del python3 make g++ && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
COPY --from=server-builder /app/server/dist ./server/dist
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||
ln -s /app/uploads /app/server/uploads && \
|
||||
ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -36,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public issue
|
||||
2. Email: **mauriceboe@icloud.com**
|
||||
2. Email: **report@liketrek.com**
|
||||
3. Include a description of the vulnerability and steps to reproduce
|
||||
|
||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
CLIENT_DIR="$REPO_ROOT/client"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||
|
||||
echo "==> Installing client dependencies"
|
||||
cd "$CLIENT_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Building client"
|
||||
npm run build
|
||||
|
||||
echo "==> Installing server dependencies"
|
||||
cd "$SERVER_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Populating server/public"
|
||||
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||
|
||||
echo "==> Done — server/public is ready"
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.12
|
||||
version: 3.0.22
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.12"
|
||||
appVersion: "3.0.22"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
+19
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.12",
|
||||
"name": "@trek/client",
|
||||
"version": "3.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,12 +12,17 @@
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@trek/shared": "*",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
@@ -34,6 +39,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -56,6 +62,15 @@
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^3.2.4",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"typescript-eslint": "^8.58.2"
|
||||
}
|
||||
}
|
||||
|
||||
+161
-182
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
+169
-135
@@ -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>
|
||||
@@ -218,7 +252,7 @@ export default function App() {
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -302,5 +336,5 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</TranslationProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+131
-66
@@ -1,5 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import en from '../i18n/translations/en'
|
||||
import br from '../i18n/translations/br'
|
||||
import de from '../i18n/translations/de'
|
||||
@@ -33,6 +35,7 @@ function translateRateLimit(): string {
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -42,24 +45,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||
|
||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
@@ -68,36 +71,84 @@ export function isAuthPublicPath(pathname: string): boolean {
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
||||
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||
async function unregisterSWAndReload(): Promise<void> {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker?.getRegistration()
|
||||
if (reg) await reg.unregister()
|
||||
} catch { /* ignore */ }
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
(response) => {
|
||||
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||
// as a CORS error with no response object. Probe the health endpoint to
|
||||
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||
if (!error.response && navigator.onLine) {
|
||||
await probeNow()
|
||||
// Both the original request and the health probe failed while the device
|
||||
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||
// down would also land here, but only reloads once).
|
||||
if (!isReachable()) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
error.message = translated
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
error.message = translated
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
@@ -142,6 +193,7 @@ export const oauthApi = {
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
resource?: string
|
||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||
|
||||
/** Submit user consent (approve or deny) */
|
||||
@@ -153,12 +205,13 @@ export const oauthApi = {
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
approved: boolean
|
||||
resource?: string
|
||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||
},
|
||||
@@ -215,11 +268,11 @@ export const placesApi = {
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -313,7 +366,7 @@ export const adminApi = {
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||
@@ -322,7 +375,7 @@ export const adminApi = {
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
@@ -355,8 +408,20 @@ export const journeyApi = {
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
@@ -387,7 +452,7 @@ export const journeyApi = {
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
@@ -437,13 +502,13 @@ export const reservationsApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
@@ -529,21 +594,21 @@ export const notificationsApi = {
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
markAllRead: () =>
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
export default apiClient
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const escapeHtml = (str) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '© <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;
|
||||
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
X, Pencil, Trash2, MapPin, Clock, Camera,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
Camera,
|
||||
Clock,
|
||||
Cloud,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Frown,
|
||||
Laugh,
|
||||
MapPin,
|
||||
Meh,
|
||||
Pencil,
|
||||
Smile,
|
||||
Snowflake,
|
||||
Sun,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore';
|
||||
import { formatLocationName } from '../../utils/formatters';
|
||||
import JournalBody from './JournalBody';
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||
}
|
||||
amazing: {
|
||||
icon: Laugh,
|
||||
label: 'Amazing',
|
||||
bg: 'bg-pink-50 dark:bg-pink-900/20',
|
||||
text: 'text-pink-600 dark:text-pink-400',
|
||||
},
|
||||
good: {
|
||||
icon: Smile,
|
||||
label: 'Good',
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
neutral: {
|
||||
icon: Meh,
|
||||
label: 'Neutral',
|
||||
bg: 'bg-zinc-100 dark:bg-zinc-800',
|
||||
text: 'text-zinc-500 dark:text-zinc-400',
|
||||
},
|
||||
rough: {
|
||||
icon: Frown,
|
||||
label: 'Rough',
|
||||
bg: 'bg-violet-50 dark:bg-violet-900/20',
|
||||
text: 'text-violet-600 dark:text-violet-400',
|
||||
},
|
||||
};
|
||||
|
||||
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
};
|
||||
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
|
||||
if (builder) return builder(p.photo_id)
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
function photoUrl(
|
||||
p: JourneyPhoto,
|
||||
size: 'thumbnail' | 'original' = 'original',
|
||||
builder?: (id: number) => string
|
||||
): string {
|
||||
if (builder) return builder(p.photo_id);
|
||||
return `/api/photos/${p.photo_id}/${size}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
entry: JourneyEntry;
|
||||
readOnly?: boolean;
|
||||
publicPhotoUrl?: (photoId: number) => string;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void;
|
||||
}
|
||||
|
||||
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
export default function MobileEntryView({
|
||||
entry,
|
||||
readOnly,
|
||||
publicPhotoUrl,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPhotoClick,
|
||||
}: Props) {
|
||||
const photos = entry.photos || [];
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null;
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null;
|
||||
const prosArr = entry.pros_cons?.pros ?? [];
|
||||
const consArr = entry.pros_cons?.cons ?? [];
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0;
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
const date = new Date(entry.entry_date + 'T00:00:00');
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex flex-col overflow-hidden bg-white dark:bg-zinc-950"
|
||||
style={{ height: '100dvh' }}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => { onClose(); onEdit(); }}
|
||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onEdit();
|
||||
}}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg bg-zinc-100 px-3 text-[12px] font-medium text-zinc-700 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onDelete();
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
@@ -81,32 +135,31 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
{/* Hero photo(s) */}
|
||||
{photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||
className="max-h-[50vh] w-full cursor-pointer object-cover"
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-black/60 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
<Camera size={12} />
|
||||
{photos.length} photos
|
||||
</div>
|
||||
)}
|
||||
{/* Photo strip for multiple photos */}
|
||||
{photos.length > 1 && (
|
||||
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||
<div className="flex gap-1 overflow-x-auto bg-zinc-50 px-4 py-2 dark:bg-zinc-900">
|
||||
{photos.map((p, i) => (
|
||||
<img
|
||||
key={p.id || i}
|
||||
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
className="h-16 w-16 flex-shrink-0 cursor-pointer rounded-lg object-cover ring-zinc-900/30 transition-all hover:ring-2 dark:ring-white/30"
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
/>
|
||||
))}
|
||||
@@ -117,9 +170,8 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-5 pb-32">
|
||||
|
||||
{/* Date + time + location header */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
|
||||
@@ -131,8 +183,8 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
|
||||
{entry.location_name && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[12px] font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<MapPin size={12} className="flex-shrink-0 text-zinc-500 dark:text-zinc-400" />
|
||||
{formatLocationName(entry.location_name)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,22 +192,24 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
|
||||
{/* Title */}
|
||||
{entry.title && (
|
||||
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
|
||||
<h1 className="mb-4 text-[22px] font-bold leading-tight tracking-tight text-zinc-900 dark:text-white">
|
||||
{entry.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Mood + Weather chips */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold ${mood.bg} ${mood.text}`}
|
||||
>
|
||||
<mood.icon size={13} />
|
||||
{mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-semibold text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<weather.icon size={13} />
|
||||
{weather.label}
|
||||
</span>
|
||||
@@ -165,16 +219,19 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
|
||||
<div className="mb-5 text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
<div className="mb-5 flex flex-wrap gap-1.5">
|
||||
{entry.tags.map((tag, i) => (
|
||||
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -183,16 +240,16 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
|
||||
<div className="mb-5 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700">
|
||||
{prosArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
<ThumbsUp size={12} /> Pros
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{prosArr.map((p, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-emerald-500 mt-0.5">+</span> {p}
|
||||
<li key={i} className="flex items-start gap-2 text-[13px] text-zinc-700 dark:text-zinc-300">
|
||||
<span className="mt-0.5 text-emerald-500">+</span> {p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -203,13 +260,13 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-red-500 dark:text-red-400">
|
||||
<ThumbsDown size={12} /> Cons
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{consArr.map((c, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-red-500 mt-0.5">−</span> {c}
|
||||
<li key={i} className="flex items-start gap-2 text-[13px] text-zinc-700 dark:text-zinc-300">
|
||||
<span className="mt-0.5 text-red-500">−</span> {c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -220,5 +277,5 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { DAY_COLORS } from './dayColors'
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { JourneyEntry } from '../../store/journeyStore';
|
||||
import { DAY_COLORS } from './dayColors';
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
import JourneyMap from './JourneyMap';
|
||||
import MobileEntryCard from './MobileEntryCard';
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
title?: string | null;
|
||||
mood?: string | null;
|
||||
entry_date: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: JourneyEntry[] | any[]
|
||||
mapEntries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
dark?: boolean
|
||||
readOnly?: boolean
|
||||
onEntryClick: (entry: any) => void
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
carouselBottom?: string
|
||||
entries: JourneyEntry[] | any[];
|
||||
mapEntries: MapEntry[];
|
||||
trail?: { lat: number; lng: number }[];
|
||||
dark?: boolean;
|
||||
readOnly?: boolean;
|
||||
onEntryClick: (entry: any) => void;
|
||||
onAddEntry?: () => void;
|
||||
publicPhotoUrl?: (photoId: number) => string;
|
||||
carouselBottom?: string;
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
@@ -38,108 +38,122 @@ export default function MobileMapTimeline({
|
||||
publicPhotoUrl,
|
||||
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const mapRef = useRef<JourneyMapHandle>(null);
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const entryDayMeta = useMemo(() => {
|
||||
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
|
||||
const counters = new Map<string, number>()
|
||||
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())];
|
||||
const counters = new Map<string, number>();
|
||||
return entries.map((e: any) => {
|
||||
const dayIdx = uniqueDates.indexOf(e.entry_date)
|
||||
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
|
||||
counters.set(e.entry_date, dayLabel)
|
||||
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
|
||||
})
|
||||
}, [entries])
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
const dayIdx = uniqueDates.indexOf(e.entry_date);
|
||||
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1;
|
||||
counters.set(e.entry_date, dayLabel);
|
||||
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] };
|
||||
});
|
||||
}, [entries]);
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
const syncMapToCarousel = useCallback(
|
||||
(index: number) => {
|
||||
const entry = entries[index];
|
||||
if (!entry) return;
|
||||
|
||||
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
|
||||
if (mapEntry) {
|
||||
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
|
||||
} else {
|
||||
try { mapRef.current?.highlightMarker(null) } catch {}
|
||||
}
|
||||
}, [entries, mapEntries])
|
||||
const mapEntry = mapEntries.find((m) => String(m.id) === String(entry.id));
|
||||
if (mapEntry) {
|
||||
try {
|
||||
mapRef.current?.focusMarker(String(mapEntry.id));
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
mapRef.current?.highlightMarker(null);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
[entries, mapEntries]
|
||||
);
|
||||
|
||||
// Pick the card that's currently closest to the carousel horizontal center.
|
||||
// More stable than IntersectionObserver thresholds when the active card can
|
||||
// drift toward the viewport edge with proximity snapping.
|
||||
const pickNearestCard = useCallback(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el) return
|
||||
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
|
||||
let bestIdx = 0
|
||||
let bestDist = Infinity
|
||||
const el = carouselRef.current;
|
||||
if (!el) return;
|
||||
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2;
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
cardRefs.current.forEach((node, idx) => {
|
||||
const r = node.getBoundingClientRect()
|
||||
const cardCenter = r.left + r.width / 2
|
||||
const d = Math.abs(cardCenter - containerCenter)
|
||||
if (d < bestDist) { bestDist = d; bestIdx = idx }
|
||||
})
|
||||
setActiveIndex(prev => {
|
||||
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
|
||||
return bestIdx
|
||||
})
|
||||
}, [syncMapToCarousel])
|
||||
const r = node.getBoundingClientRect();
|
||||
const cardCenter = r.left + r.width / 2;
|
||||
const d = Math.abs(cardCenter - containerCenter);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
setActiveIndex((prev) => {
|
||||
if (prev !== bestIdx) syncMapToCarousel(bestIdx);
|
||||
return bestIdx;
|
||||
});
|
||||
}, [syncMapToCarousel]);
|
||||
|
||||
// Defer all state updates until scrolling settles — updating activeIndex
|
||||
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
let settleTimer: number | null = null
|
||||
const el = carouselRef.current;
|
||||
if (!el || entries.length === 0) return;
|
||||
let settleTimer: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = window.setTimeout(pickNearestCard, 150);
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
}
|
||||
}, [entries.length, pickNearestCard])
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
};
|
||||
}, [entries.length, pickNearestCard]);
|
||||
|
||||
// Scroll a given card into the horizontal center of the carousel
|
||||
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||
const card = cardRefs.current.get(idx)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, [])
|
||||
const card = cardRefs.current.get(idx);
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}, []);
|
||||
|
||||
// Scroll carousel to entry when map marker is clicked
|
||||
const handleMarkerClick = useCallback((id: string) => {
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||
if (idx === -1) return
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}, [entries, scrollCardIntoCenter])
|
||||
const handleMarkerClick = useCallback(
|
||||
(id: string) => {
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === id);
|
||||
if (idx === -1) return;
|
||||
setActiveIndex(idx);
|
||||
scrollCardIntoCenter(idx);
|
||||
},
|
||||
[entries, scrollCardIntoCenter]
|
||||
);
|
||||
|
||||
// Tap on a card: if it's already active, open the edit view; otherwise
|
||||
// activate + center it first (don't jump straight into the editor).
|
||||
const handleCardTap = useCallback((entry: any, idx: number) => {
|
||||
if (idx === activeIndex) {
|
||||
onEntryClick(entry)
|
||||
} else {
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}
|
||||
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||
const handleCardTap = useCallback(
|
||||
(entry: any, idx: number) => {
|
||||
if (idx === activeIndex) {
|
||||
onEntryClick(entry);
|
||||
} else {
|
||||
setActiveIndex(idx);
|
||||
scrollCardIntoCenter(idx);
|
||||
}
|
||||
},
|
||||
[activeIndex, onEntryClick, scrollCardIntoCenter]
|
||||
);
|
||||
|
||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
const timer = setTimeout(() => syncMapToCarousel(0), 500)
|
||||
return () => clearTimeout(timer)
|
||||
const timer = setTimeout(() => syncMapToCarousel(0), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [entries.length])
|
||||
}, [entries.length]);
|
||||
|
||||
const activeEntryId = entries[activeIndex]
|
||||
? String(entries[activeIndex].id)
|
||||
: null
|
||||
const activeEntryId = entries[activeIndex] ? String(entries[activeIndex].id) : null;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
@@ -161,14 +175,14 @@ export default function MobileMapTimeline({
|
||||
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg transition-transform hover:scale-105 active:scale-95 dark:bg-white dark:text-zinc-900"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -191,10 +205,7 @@ export default function MobileMapTimeline({
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||
>
|
||||
<div className="fixed left-0 right-0 z-40" style={{ touchAction: 'pan-x', bottom: carouselBottom }}>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||
@@ -209,7 +220,10 @@ export default function MobileMapTimeline({
|
||||
<div
|
||||
key={entry.id}
|
||||
data-idx={i}
|
||||
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||
ref={(node) => {
|
||||
if (node) cardRefs.current.set(i, node);
|
||||
else cardRefs.current.delete(i);
|
||||
}}
|
||||
style={{ scrollSnapAlign: 'center' }}
|
||||
>
|
||||
<MobileEntryCard
|
||||
@@ -227,18 +241,15 @@ export default function MobileMapTimeline({
|
||||
|
||||
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
||||
{!readOnly && onAddEntry && (
|
||||
<div
|
||||
className="fixed right-4 z-30"
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||
>
|
||||
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg transition-transform hover:scale-105 active:scale-95 dark:bg-white dark:text-zinc-900"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
|
||||
|
||||
@@ -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,11 +16,13 @@ vi.mock('react-router-dom', async () => {
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { buildSettings, buildUser } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import BottomNav from './BottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
@@ -39,7 +41,7 @@ describe('BottomNav', () => {
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||
@@ -99,4 +101,39 @@ describe('BottomNav', () => {
|
||||
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
|
||||
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Profil')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
seedStore(useAddonStore, {
|
||||
addons: [
|
||||
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
|
||||
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
|
||||
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
|
||||
],
|
||||
});
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Vacances')).toBeInTheDocument();
|
||||
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
|
||||
});
|
||||
render(<BottomNav />);
|
||||
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { CalendarDays, Compass, Globe, LogOut, Plane, Settings, Shield, User } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
|
||||
{ to: '/trips', label: 'Trips', icon: Plane },
|
||||
]
|
||||
|
||||
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
|
||||
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
|
||||
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
|
||||
journey: { to: '/journey', label: 'Journey', icon: Compass },
|
||||
}
|
||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
|
||||
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
|
||||
};
|
||||
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation()
|
||||
const darkMode = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const addons = useAddonStore(s => s.addons)
|
||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||
const [showProfile, setShowProfile] = useState(false)
|
||||
const { t } = useTranslation();
|
||||
const darkMode = useSettingsStore((s) => s.settings.dark_mode);
|
||||
const dark =
|
||||
darkMode === true ||
|
||||
darkMode === 'dark' ||
|
||||
(darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const addons = useAddonStore((s) => s.addons);
|
||||
const globalAddons = addons.filter((a) => a.type === 'global' && a.enabled);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
|
||||
const items = [...BASE_ITEMS]
|
||||
for (const addon of globalAddons) {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
if (nav) items.push(nav)
|
||||
}
|
||||
const items: { to: string; label: string; icon: LucideIcon }[] = [
|
||||
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
|
||||
...globalAddons.flatMap((addon) => {
|
||||
const nav = ADDON_NAV[addon.id];
|
||||
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : [];
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
|
||||
className="sticky bottom-0 z-50 mt-auto flex flex-shrink-0 items-start justify-around border-t border-zinc-200 pt-3 dark:border-zinc-800 md:hidden"
|
||||
style={{
|
||||
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
@@ -48,7 +49,7 @@ export default function BottomNav() {
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
|
||||
`flex min-w-[60px] flex-col items-center gap-1 px-3 py-1 ${
|
||||
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
|
||||
}`
|
||||
}
|
||||
@@ -59,33 +60,33 @@ export default function BottomNav() {
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowProfile(true)}
|
||||
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
|
||||
className="flex min-w-[60px] flex-col items-center gap-1 px-3 py-1 text-zinc-400 dark:text-zinc-500"
|
||||
>
|
||||
<User size={22} strokeWidth={2} />
|
||||
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
|
||||
<span className="text-[10px] font-medium">{t('nav.profile')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSheet({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNav = (path: string) => {
|
||||
onClose()
|
||||
navigate(path)
|
||||
}
|
||||
onClose();
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose()
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
onClose();
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
|
||||
@@ -94,71 +95,71 @@ function ProfileSheet({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
|
||||
className="absolute bottom-0 left-0 right-0 overflow-hidden rounded-t-2xl bg-white dark:bg-zinc-900"
|
||||
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
<div className="flex justify-center pb-2 pt-3">
|
||||
<div className="h-1 w-10 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
<div className="px-6 pb-4 pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-zinc-900 text-[16px] font-bold text-white dark:bg-white dark:text-zinc-900">
|
||||
{(user?.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
|
||||
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
|
||||
<p className="truncate text-[12px] text-zinc-500">{user?.email}</p>
|
||||
</div>
|
||||
{user?.role === 'admin' && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
|
||||
<span className="flex items-center gap-1 rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<Shield size={10} /> Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||
<div className="mx-4 h-px bg-zinc-100 dark:bg-zinc-800" />
|
||||
|
||||
{/* Links */}
|
||||
<div className="py-2 px-2">
|
||||
<div className="px-2 py-2">
|
||||
<button
|
||||
onClick={() => handleNav('/settings')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 dark:hover:bg-zinc-800 dark:active:bg-zinc-800"
|
||||
>
|
||||
<Settings size={18} className="text-zinc-500" />
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t('nav.bottomSettings')}</span>
|
||||
</button>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => handleNav('/admin')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 dark:hover:bg-zinc-800 dark:active:bg-zinc-800"
|
||||
>
|
||||
<Shield size={18} className="text-zinc-500" />
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
|
||||
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t('nav.bottomAdmin')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
||||
<div className="mx-4 h-px bg-zinc-100 dark:bg-zinc-800" />
|
||||
|
||||
{/* Logout */}
|
||||
<div className="py-2 px-2">
|
||||
<div className="px-2 py-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition-colors hover:bg-red-50 active:bg-red-100 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<LogOut size={18} className="text-red-500" />
|
||||
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
|
||||
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t('nav.bottomLogout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoBanner from './DemoBanner';
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
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(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
getZoom: vi.fn().mockReturnValue(10),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
Marker: ({ children, eventHandlers, position }: any) => (
|
||||
<div
|
||||
data-testid="marker"
|
||||
data-lat={position[0]}
|
||||
data-lng={position[1]}
|
||||
onClick={() => eventHandlers?.click?.()}
|
||||
>
|
||||
<div data-testid="marker" data-lat={position[0]} data-lng={position[1]} onClick={() => eventHandlers?.click?.()}>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
@@ -27,21 +31,13 @@ vi.mock('react-leaflet', () => ({
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
useMap: () => ({
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
getZoom: () => 10,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
}),
|
||||
useMap: () => mapMock,
|
||||
useMapEvents: () => ({}),
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('react-leaflet-cluster', () => ({
|
||||
default: ({ children }: any) => <div data-testid="cluster-group">{children}</div>,
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('leaflet', () => ({
|
||||
default: {
|
||||
@@ -54,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),
|
||||
@@ -62,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.
|
||||
@@ -75,145 +71,177 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||
category_color: null,
|
||||
category_icon: null,
|
||||
...overrides,
|
||||
} as any
|
||||
} as any;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('MapView', () => {
|
||||
it('FE-COMP-MAPVIEW-001: renders map container', () => {
|
||||
render(<MapView />)
|
||||
expect(screen.getByTestId('map-container')).toBeTruthy()
|
||||
})
|
||||
render(<MapView />);
|
||||
expect(screen.getByTestId('map-container')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEW-002: renders one marker per place', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
buildMapPlace({ id: 2, name: 'Louvre', lat: 48.86, lng: 2.337 }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
expect(screen.getAllByTestId('marker').length).toBe(2)
|
||||
})
|
||||
];
|
||||
render(<MapView places={places} />);
|
||||
expect(screen.getAllByTestId('marker').length).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEW-003: marker click calls onMarkerClick with place id', () => {
|
||||
const onMarkerClick = vi.fn()
|
||||
const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} onMarkerClick={onMarkerClick} />)
|
||||
fireEvent.click(screen.getByTestId('marker'))
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(42)
|
||||
})
|
||||
const onMarkerClick = vi.fn();
|
||||
const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })];
|
||||
render(<MapView places={places} onMarkerClick={onMarkerClick} />);
|
||||
fireEvent.click(screen.getByTestId('marker'));
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', 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();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 5, lat: 48.8584, lng: 2.2945 }),
|
||||
]
|
||||
render(<MapView places={places} selectedPlaceId={5} />)
|
||||
expect(screen.getByTestId('marker')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
|
||||
];
|
||||
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />);
|
||||
const initialCount = mapMock.fitBounds.mock.calls.length;
|
||||
|
||||
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
|
||||
// paddingOpts memo creates new object). fitBounds must NOT fire again.
|
||||
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount);
|
||||
|
||||
// Toggle selectedPlaceId off — mimics closing inspector via X button.
|
||||
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />);
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount);
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
|
||||
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })];
|
||||
const { rerender } = render(<MapView places={places} fitKey={1} />);
|
||||
const afterFirst = mapMock.fitBounds.mock.calls.length;
|
||||
|
||||
rerender(<MapView places={places} fitKey={2} />);
|
||||
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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,139 +114,131 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
tooltipAnchor: [size / 2 + 6, 0],
|
||||
})
|
||||
iconCache.set(cacheKey, fallbackIcon)
|
||||
return fallbackIcon
|
||||
});
|
||||
iconCache.set(cacheKey, fallbackIcon);
|
||||
return fallbackIcon;
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
dayPlaces: Place[]
|
||||
paddingOpts: Record<string, number>
|
||||
places: Place[];
|
||||
selectedPlaceId: number | null;
|
||||
dayPlaces: Place[];
|
||||
paddingOpts: Record<string, number>;
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
|
||||
const map = useMap()
|
||||
const prev = useRef(null)
|
||||
const map = useMap();
|
||||
const prev = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||
// Pan to the selected place without changing zoom
|
||||
const selected = places.find(p => p.id === selectedPlaceId)
|
||||
const selected = places.find((p) => p.id === selectedPlaceId);
|
||||
if (selected?.lat && selected?.lng) {
|
||||
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||
map.panTo([selected.lat, selected.lng], { animate: true });
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
}, [selectedPlaceId, places, map])
|
||||
prev.current = selectedPlaceId;
|
||||
}, [selectedPlaceId, places, map]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MapControllerProps {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
function MapController({ center, zoom }: MapControllerProps) {
|
||||
const map = useMap()
|
||||
const prevCenter = useRef(center)
|
||||
const map = useMap();
|
||||
const prevCenter = useRef(center);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
|
||||
map.setView(center, zoom)
|
||||
prevCenter.current = center
|
||||
map.setView(center, zoom);
|
||||
prevCenter.current = center;
|
||||
}
|
||||
}, [center, zoom, map])
|
||||
}, [center, zoom, map]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fit bounds when places change (fitKey triggers re-fit)
|
||||
interface BoundsControllerProps {
|
||||
hasDayDetail?: boolean
|
||||
places: Place[]
|
||||
fitKey: number
|
||||
paddingOpts: Record<string, number>
|
||||
hasDayDetail?: boolean;
|
||||
places: Place[];
|
||||
fitKey: number;
|
||||
paddingOpts: Record<string, number>;
|
||||
}
|
||||
|
||||
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const map = useMap();
|
||||
const prevFitKey = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (fitKey === prevFitKey.current) return
|
||||
prevFitKey.current = fitKey
|
||||
if (places.length === 0) return
|
||||
if (fitKey === prevFitKey.current) return;
|
||||
prevFitKey.current = fitKey;
|
||||
if (places.length === 0) return;
|
||||
try {
|
||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||
const bounds = L.latLngBounds(places.map((p) => [p.lat, p.lng]));
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true });
|
||||
if (hasDayDetail) {
|
||||
setTimeout(() => map.panBy([0, 150], { animate: true }), 300)
|
||||
setTimeout(() => map.panBy([0, 150], { animate: true }), 300);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [fitKey, places, paddingOpts, map, hasDayDetail])
|
||||
}, [fitKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null;
|
||||
}
|
||||
|
||||
function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) {
|
||||
const map = useMap()
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.on('zoomstart', onZoomStart)
|
||||
map.on('zoomend', onZoomEnd)
|
||||
return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) }
|
||||
}, [map, onZoomStart, onZoomEnd])
|
||||
return null
|
||||
map.on('zoomstart', onZoomStart);
|
||||
map.on('zoomend', onZoomEnd);
|
||||
return () => {
|
||||
map.off('zoomstart', onZoomStart);
|
||||
map.off('zoomend', onZoomEnd);
|
||||
};
|
||||
}, [map, onZoomStart, onZoomEnd]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!onClick) return
|
||||
map.on('click', onClick)
|
||||
return () => map.off('click', onClick)
|
||||
}, [map, onClick])
|
||||
return null
|
||||
if (!onClick) return;
|
||||
map.on('click', onClick);
|
||||
return () => map.off('click', onClick);
|
||||
}, [map, onClick]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
|
||||
const map = useMap()
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!onContextMenu) return
|
||||
map.on('contextmenu', onContextMenu)
|
||||
return () => map.off('contextmenu', onContextMenu)
|
||||
}, [map, onContextMenu])
|
||||
return null
|
||||
if (!onContextMenu) return;
|
||||
map.on('contextmenu', onContextMenu);
|
||||
return () => map.off('contextmenu', onContextMenu);
|
||||
}, [map, onContextMenu]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Route travel time label ──
|
||||
interface RouteLabelProps {
|
||||
midpoint: [number, number]
|
||||
walkingText: string
|
||||
drivingText: string
|
||||
midpoint: [number, number];
|
||||
walkingText: string;
|
||||
drivingText: string;
|
||||
}
|
||||
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
const map = useMap()
|
||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const check = () => setVisible(map.getZoom() >= 12)
|
||||
check()
|
||||
map.on('zoomend', check)
|
||||
return () => map.off('zoomend', check)
|
||||
}, [map])
|
||||
|
||||
if (!visible || !midpoint) return null
|
||||
if (!midpoint) return null;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'route-info-pill',
|
||||
@@ -270,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);
|
||||
@@ -322,7 +330,7 @@ function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null
|
||||
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
pointer-events:none;
|
||||
"></div>`,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -335,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]}
|
||||
@@ -349,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]}
|
||||
@@ -378,8 +387,8 @@ const MemoMarker = memo(function MemoMarker({
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
/>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
@@ -405,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='© <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='© <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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
loaded: vi.fn().mockReturnValue(true),
|
||||
fitBounds: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
getZoom: vi.fn().mockReturnValue(10),
|
||||
addControl: vi.fn(),
|
||||
removeControl: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
addSource: vi.fn(),
|
||||
getSource: vi.fn().mockReturnValue(null),
|
||||
addLayer: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => document.createElement('div')),
|
||||
}));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: vi.fn(() => glMap),
|
||||
Marker: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => document.createElement('div')),
|
||||
})),
|
||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||
NavigationControl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
|
||||
vi.mock('./mapboxSetup', () => ({
|
||||
isStandardFamily: vi.fn(() => false),
|
||||
supportsCustom3d: vi.fn(() => false),
|
||||
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(() => ({
|
||||
position: null,
|
||||
mode: 'off',
|
||||
error: null,
|
||||
cycleMode: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { MapViewGL } from './MapViewGL';
|
||||
|
||||
function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
...buildPlace(),
|
||||
category_name: null,
|
||||
category_color: null,
|
||||
category_icon: null,
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
map_provider: 'mapbox-gl',
|
||||
mapbox_access_token: 'pk.test_token',
|
||||
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
|
||||
mapbox_3d_enabled: false,
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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;
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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 { 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);
|
||||
});
|
||||
|
||||
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 { 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);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +1,86 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { useGeolocation } from '../../hooks/useGeolocation';
|
||||
import { fetchPhoto, getAllThumbs, getCached, isLoading, onThumbReady } from '../../services/photoService';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { Place, Reservation } from '../../types';
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons';
|
||||
import LocationButton from './LocationButton';
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox';
|
||||
import {
|
||||
addCustom3dBuildings,
|
||||
addTerrainAndSky,
|
||||
isStandardFamily,
|
||||
supportsCustom3d,
|
||||
wantsTerrain,
|
||||
} from './mapboxSetup';
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox';
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'];
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface RouteSegment {
|
||||
mid: [number, number]
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
walkingText?: string
|
||||
drivingText?: string
|
||||
mid: [number, number];
|
||||
from: [number, number];
|
||||
to: [number, number];
|
||||
walkingText?: string;
|
||||
drivingText?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
places: Place[]
|
||||
dayPlaces?: Place[]
|
||||
route?: [number, number][][] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
onMarkerClick?: (id: number) => void
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
fitKey?: number | null
|
||||
dayOrderMap?: Record<number, number[] | null>
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
hasInspector?: boolean
|
||||
hasDayDetail?: boolean
|
||||
reservations?: Reservation[]
|
||||
visibleConnectionIds?: number[]
|
||||
showReservationStats?: boolean
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
places: Place[];
|
||||
dayPlaces?: Place[];
|
||||
route?: [number, number][][] | null;
|
||||
routeSegments?: RouteSegment[];
|
||||
selectedPlaceId?: number | null;
|
||||
onMarkerClick?: (id: number) => void;
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void;
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
fitKey?: number | null;
|
||||
dayOrderMap?: Record<number, number[] | null>;
|
||||
leftWidth?: number;
|
||||
rightWidth?: number;
|
||||
hasInspector?: boolean;
|
||||
hasDayDetail?: boolean;
|
||||
reservations?: Reservation[];
|
||||
visibleConnectionIds?: number[];
|
||||
showReservationStats?: boolean;
|
||||
onReservationClick?: (reservationId: number) => void;
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
const size = selected ? 44 : 36
|
||||
const borderColor = selected ? '#111827' : 'white'
|
||||
const borderWidth = selected ? 3 : 2.5
|
||||
const shadow = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const bgColor = place.category_color || '#6b7280'
|
||||
function createMarkerElement(
|
||||
place: Place & { category_color?: string; category_icon?: string },
|
||||
photoUrl: string | null,
|
||||
orderNumbers: number[] | null,
|
||||
selected: boolean
|
||||
): HTMLDivElement {
|
||||
const size = selected ? 44 : 36;
|
||||
const borderColor = selected ? '#111827' : 'white';
|
||||
const borderWidth = selected ? 3 : 2.5;
|
||||
const shadow = selected ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.22)';
|
||||
const bgColor = place.category_color || '#6b7280';
|
||||
|
||||
// The visual circle is `size` + 2*border on each side. To make the
|
||||
// mapbox `anchor: 'center'` land on the real visual middle of the marker
|
||||
// (rather than just the inner content box), the wrapper has to be the
|
||||
// full outer size. If we gave the wrapper only `size`, the border would
|
||||
// bleed outside it and the route lines would appear slightly off.
|
||||
const outer = size + borderWidth * 2
|
||||
const outer = size + borderWidth * 2;
|
||||
|
||||
let badgeHtml = ''
|
||||
let badgeHtml = '';
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
const label = orderNumbers.join(' · ');
|
||||
badgeHtml = `<span style="
|
||||
position:absolute;bottom:-2px;right:-2px;
|
||||
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
||||
@@ -81,10 +92,10 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||
box-sizing:border-box;white-space:nowrap;
|
||||
">${label}</span>`
|
||||
">${label}</span>`;
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
const wrap = document.createElement('div');
|
||||
// Do NOT set `position: relative` here — mapbox-gl ships
|
||||
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
||||
// `position: relative` here overrides the class, turns every marker into
|
||||
@@ -92,9 +103,9 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
||||
// canvas container. The result looks exactly like "markers drift as the
|
||||
// map zooms" because each marker's transform is then applied relative
|
||||
// to its stacked slot, not to the map viewport.
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`;
|
||||
|
||||
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
|
||||
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'));
|
||||
if (hasPhoto) {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
@@ -108,7 +119,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
||||
<img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
`;
|
||||
} else {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
@@ -123,15 +134,16 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
||||
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
`;
|
||||
}
|
||||
return wrap
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -149,33 +161,40 @@ export function MapViewGL({
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
const mapboxStyle = useSettingsStore((s) => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard');
|
||||
const mapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token || '');
|
||||
const mapbox3d = useSettingsStore((s) => s.settings.mapbox_3d_enabled !== false);
|
||||
const mapboxQuality = useSettingsStore((s) => s.settings.mapbox_quality_mode === true);
|
||||
const showEndpointLabels = useSettingsStore((s) => s.settings.map_booking_labels) !== false;
|
||||
const placesPhotosEnabled = useAuthStore((s) => s.placesPhotosEnabled);
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map());
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null);
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null);
|
||||
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
onClickRefs.current.map = onMapClick
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
const onReservationClickRef = useRef(onReservationClick);
|
||||
onReservationClickRef.current = onReservationClick;
|
||||
const {
|
||||
position: userPosition,
|
||||
mode: trackingMode,
|
||||
error: trackingError,
|
||||
cycleMode: cycleTrackingMode,
|
||||
setMode: setTrackingMode,
|
||||
} = useGeolocation();
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu });
|
||||
onClickRefs.current.marker = onMarkerClick;
|
||||
onClickRefs.current.map = onMapClick;
|
||||
onClickRefs.current.context = onMapContextMenu;
|
||||
|
||||
// Build/rebuild the map on style/token/3d change
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
if (!containerRef.current || !mapboxToken) return;
|
||||
mapboxgl.accessToken = mapboxToken;
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: containerRef.current,
|
||||
@@ -186,20 +205,20 @@ export function MapViewGL({
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
});
|
||||
mapRef.current = map;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).__trek_map = map
|
||||
(window as any).__trek_map = map;
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
// Terrain is only valuable on satellite styles — on clean vector
|
||||
// styles it makes route lines drift off the HTML markers because
|
||||
// the lines snap to DEM height while markers stay at sea level.
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map);
|
||||
if (supportsCustom3d(mapboxStyle)) {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
addCustom3dBuildings(map, dark)
|
||||
const dark = document.documentElement.classList.contains('dark');
|
||||
addCustom3dBuildings(map, dark);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,11 +230,15 @@ export function MapViewGL({
|
||||
// so flatten it out to keep markers pinned. (Satellite variants
|
||||
// are left alone — the DEM is what gives them their character.)
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
try {
|
||||
map.setTerrain(null);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
@@ -227,11 +250,11 @@ export function MapViewGL({
|
||||
'line-dasharray': [2, 1.5],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
});
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-line',
|
||||
type: 'line',
|
||||
@@ -242,46 +265,46 @@ export function MapViewGL({
|
||||
'line-opacity': 0.75,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
});
|
||||
}
|
||||
// Signal that sources/layers are attached so overlay effects can
|
||||
// safely add their own sources. Style rebuilds reset this via the
|
||||
// cleanup below.
|
||||
setMapReady(true)
|
||||
})
|
||||
setMapReady(true);
|
||||
});
|
||||
|
||||
map.on('click', (e) => {
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
const t = e.originalEvent.target as HTMLElement;
|
||||
if (t.closest('.mapboxgl-marker')) return; // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } });
|
||||
});
|
||||
// In the mapbox-gl map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
const canvas = map.getCanvasContainer()
|
||||
const canvas = map.getCanvasContainer();
|
||||
const onAuxDown = (ev: MouseEvent) => {
|
||||
if (ev.button !== 1) return
|
||||
ev.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||
if (ev.button !== 1) return;
|
||||
ev.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top]);
|
||||
onClickRefs.current.context?.({
|
||||
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||
originalEvent: ev,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
// Also suppress the browser's native auxclick menu on middle-click.
|
||||
const onAuxClick = (ev: MouseEvent) => {
|
||||
if (ev.button === 1) ev.preventDefault()
|
||||
}
|
||||
canvas.addEventListener('mousedown', onAuxDown)
|
||||
canvas.addEventListener('auxclick', onAuxClick)
|
||||
if (ev.button === 1) ev.preventDefault();
|
||||
};
|
||||
canvas.addEventListener('mousedown', onAuxDown);
|
||||
canvas.addEventListener('auxclick', onAuxClick);
|
||||
|
||||
// Drop follow mode if the user pans the map manually — matches the
|
||||
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||
// chases it until the user taps the button again.
|
||||
map.on('dragstart', () => {
|
||||
setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
|
||||
})
|
||||
setTrackingMode((prev) => (prev === 'follow' ? 'show' : prev));
|
||||
});
|
||||
|
||||
// Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
|
||||
// HTML markers at altitude=0 (sea level) by default, so as soon as the
|
||||
@@ -293,175 +316,217 @@ export function MapViewGL({
|
||||
// Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
|
||||
// project the marker onto the same ground the route line sits on.
|
||||
// We re-apply this every render because DEM tiles stream in async.
|
||||
let lastAltUpdate = 0
|
||||
let lastAltUpdate = 0;
|
||||
const syncMarkerAltitudes = () => {
|
||||
const now = performance.now()
|
||||
if (now - lastAltUpdate < 80) return // ~12Hz is plenty
|
||||
lastAltUpdate = now
|
||||
markersRef.current.forEach(marker => {
|
||||
const ll = marker.getLngLat()
|
||||
let alt = 0
|
||||
const now = performance.now();
|
||||
if (now - lastAltUpdate < 80) return; // ~12Hz is plenty
|
||||
lastAltUpdate = now;
|
||||
markersRef.current.forEach((marker) => {
|
||||
const ll = marker.getLngLat();
|
||||
let alt = 0;
|
||||
try {
|
||||
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
||||
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
||||
} catch { /* terrain not ready */ }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const curAlt = (ll as any).alt ?? 0
|
||||
if (Math.abs(curAlt - alt) > 0.25) {
|
||||
marker.setLngLat([ll.lng, ll.lat, alt])
|
||||
const e = map.queryTerrainElevation([ll.lng, ll.lat]);
|
||||
if (typeof e === 'number' && Number.isFinite(e)) alt = e;
|
||||
} catch {
|
||||
/* terrain not ready */
|
||||
}
|
||||
})
|
||||
}
|
||||
map.on('render', syncMarkerAltitudes)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const curAlt = (ll as any).alt ?? 0;
|
||||
if (Math.abs(curAlt - alt) > 0.25) {
|
||||
marker.setLngLat([ll.lng, ll.lat, alt]);
|
||||
}
|
||||
});
|
||||
};
|
||||
map.on('render', syncMarkerAltitudes);
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
canvas.removeEventListener('mousedown', onAuxDown);
|
||||
canvas.removeEventListener('auxclick', onAuxClick);
|
||||
markersRef.current.forEach((m) => m.remove());
|
||||
markersRef.current.clear();
|
||||
if (reservationOverlayRef.current) {
|
||||
reservationOverlayRef.current.destroy()
|
||||
reservationOverlayRef.current = null
|
||||
reservationOverlayRef.current.destroy();
|
||||
reservationOverlayRef.current = null;
|
||||
}
|
||||
if (locationMarkerRef.current) {
|
||||
locationMarkerRef.current.destroy()
|
||||
locationMarkerRef.current = null
|
||||
locationMarkerRef.current.destroy();
|
||||
locationMarkerRef.current = null;
|
||||
}
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
setMapReady(false)
|
||||
}
|
||||
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
||||
try {
|
||||
map.remove();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
mapRef.current = null;
|
||||
setMapReady(false);
|
||||
};
|
||||
}, [mapboxStyle, mapboxToken, mapbox3d]); // rebuild on style changes only
|
||||
|
||||
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
||||
// simultaneous thumb arrivals into one re-render.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({});
|
||||
const thumbRafRef = useRef<number | null>(null);
|
||||
const placeIds = useMemo(() => places.map((p) => p.id).join(','), [places]);
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return;
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
pendingThumbsRef.current[cacheKey] = thumb;
|
||||
if (thumbRafRef.current !== null) return;
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
thumbRafRef.current = null;
|
||||
const pending = pendingThumbsRef.current;
|
||||
pendingThumbsRef.current = {};
|
||||
setPhotoUrls((prev) => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v);
|
||||
return hasChange ? { ...prev, ...pending } : prev;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
for (const place of places) {
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) continue
|
||||
const cached = getCached(cacheKey)
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`;
|
||||
if (!cacheKey) continue;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached?.thumbDataUrl) {
|
||||
setThumb(cacheKey, cached.thumbDataUrl)
|
||||
continue
|
||||
setThumb(cacheKey, cached.thumbDataUrl);
|
||||
continue;
|
||||
}
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
cleanups.push(onThumbReady(cacheKey, (thumb) => setThumb(cacheKey, thumb)));
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId =
|
||||
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|
||||
|| place.google_place_id
|
||||
|| place.osm_id
|
||||
|| place.image_url
|
||||
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null) ||
|
||||
place.google_place_id ||
|
||||
place.osm_id ||
|
||||
place.image_url;
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
cleanups.forEach((fn) => fn());
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
cancelAnimationFrame(thumbRafRef.current);
|
||||
thumbRafRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
};
|
||||
}, [placeIds, placesPhotosEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reconcile markers with places + photos. Rebuilds the DOM node when any
|
||||
// visual input changes so photos, selection state and order badges stay
|
||||
// in sync.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const ids = new Set(places.map(p => p.id))
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const ids = new Set(places.map((p) => p.id));
|
||||
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
if (!ids.has(id)) {
|
||||
marker.remove()
|
||||
markersRef.current.delete(id)
|
||||
marker.remove();
|
||||
markersRef.current.delete(id);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
places.forEach(place => {
|
||||
if (!place.lat || !place.lng) return
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const selected = place.id === selectedPlaceId
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
places.forEach((place) => {
|
||||
if (!place.lat || !place.lng) return;
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null;
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`;
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null;
|
||||
const selected = place.id === selectedPlaceId;
|
||||
const el = createMarkerElement(
|
||||
place as Place & { category_color?: string; category_icon?: string },
|
||||
photoUrl,
|
||||
orderNumbers,
|
||||
selected
|
||||
);
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
ev.stopPropagation();
|
||||
onClickRefs.current.marker?.(place.id);
|
||||
});
|
||||
// Recreate marker each time rather than patching internal state —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
if (existing) existing.remove()
|
||||
const existing = markersRef.current.get(place.id);
|
||||
if (existing) existing.remove();
|
||||
// Default (viewport-aligned) anchors keep the marker parallel to the
|
||||
// screen so its pixel centre lines up with the route line at any
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map)
|
||||
markersRef.current.set(place.id, m)
|
||||
})
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([place.lng, place.lat]).addTo(map);
|
||||
markersRef.current.set(place.id, m);
|
||||
});
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls]);
|
||||
|
||||
// Update route geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}))
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined;
|
||||
if (!src) return;
|
||||
const features = (route || [])
|
||||
.filter((seg) => seg && seg.length > 1)
|
||||
.map((seg) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}));
|
||||
src.setData({ type: 'FeatureCollection', features });
|
||||
}, [route]);
|
||||
|
||||
// Travel-time pills between consecutive places. The GL map accepted the
|
||||
// routeSegments prop but never drew anything, so the labels that Leaflet
|
||||
// shows were missing here (#850). Render them as HTML markers, matching the
|
||||
// Leaflet pill styling.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
routeLabelMarkersRef.current.forEach((m) => m.remove());
|
||||
routeLabelMarkersRef.current = [];
|
||||
for (const seg of routeSegments) {
|
||||
if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue;
|
||||
const el = document.createElement('div');
|
||||
el.style.pointerEvents = 'none';
|
||||
el.innerHTML = `<div style="display:flex;align-items:center;gap:5px;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);color:#fff;border-radius:99px;padding:3px 9px;font-size:9px;font-weight:600;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>${seg.walkingText ?? ''}</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>${seg.drivingText ?? ''}</span>
|
||||
</div>`;
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([seg.mid[1], seg.mid[0]]).addTo(map);
|
||||
routeLabelMarkersRef.current.push(m);
|
||||
}
|
||||
return () => {
|
||||
routeLabelMarkersRef.current.forEach((m) => m.remove());
|
||||
routeLabelMarkersRef.current = [];
|
||||
};
|
||||
}, [routeSegments, mapReady]);
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined;
|
||||
if (!src) return;
|
||||
const features = places.flatMap((place) => {
|
||||
if (!place.route_geometry) return [];
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [{
|
||||
type: 'Feature' as const,
|
||||
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
}]
|
||||
} catch { return [] }
|
||||
})
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [places])
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][];
|
||||
if (!coords || coords.length < 2) return [];
|
||||
return [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
src.setData({ type: 'FeatureCollection', features });
|
||||
}, [places]);
|
||||
|
||||
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
|
||||
// circle arcs for flights/cruises, straight lines for trains/cars,
|
||||
@@ -474,53 +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]);
|
||||
|
||||
// Also fit when the places collection changes so the initial render
|
||||
// zooms to the trip instead of the default center.
|
||||
const placeBoundsKey = useMemo(
|
||||
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
|
||||
[places]
|
||||
)
|
||||
const prevFitKey = useRef(-1);
|
||||
useEffect(() => {
|
||||
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, {
|
||||
@@ -528,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, placeBoundsKey, paddingOpts, mapbox3d]) // 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 {
|
||||
@@ -582,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}
|
||||
@@ -618,5 +691,5 @@ export function MapViewGL({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||
}
|
||||
|
||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
||||
// offset when the map is pitched. Restrict terrain to satellite.
|
||||
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||
// when the map is pitched. Satellite and Outdoors are the intended styles
|
||||
// for terrain; markers are re-pinned by syncMarkerAltitudes().
|
||||
export function wantsTerrain(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||
|| style === 'mapbox://styles/mapbox/outdoors-v12'
|
||||
}
|
||||
|
||||
// 3D can be added to every style now — the standard family has it built-in
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import MemoriesPanel from './MemoriesPanel';
|
||||
|
||||
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
|
||||
@@ -33,18 +33,10 @@ const immichAddon = {
|
||||
|
||||
// Handlers that simulate a connected provider with no photos/links
|
||||
const connectedHandlers = [
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/status', () =>
|
||||
HttpResponse.json({ connected: true })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [immichAddon] })),
|
||||
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -58,15 +50,11 @@ describe('MemoriesPanel', () => {
|
||||
// Use a delayed response so loading stays true long enough to assert
|
||||
server.use(
|
||||
http.get('/api/addons', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ addons: [] });
|
||||
}),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -78,12 +66,8 @@ describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -94,7 +78,7 @@ describe('MemoriesPanel', () => {
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
@@ -108,7 +92,7 @@ describe('MemoriesPanel', () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -128,7 +112,7 @@ describe('MemoriesPanel', () => {
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
@@ -144,7 +128,7 @@ describe('MemoriesPanel', () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -156,7 +140,7 @@ describe('MemoriesPanel', () => {
|
||||
let syncCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
@@ -176,7 +160,7 @@ describe('MemoriesPanel', () => {
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
|
||||
syncCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -193,7 +177,7 @@ describe('MemoriesPanel', () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
@@ -213,7 +197,7 @@ describe('MemoriesPanel', () => {
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -229,15 +213,31 @@ describe('MemoriesPanel', () => {
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
{
|
||||
photo_id: 1,
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
photo_id: 2,
|
||||
asset_id: 'photo2',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-10T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -254,9 +254,7 @@ describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -275,9 +273,7 @@ describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -298,9 +294,7 @@ describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -315,7 +309,7 @@ describe('MemoriesPanel', () => {
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
@@ -329,7 +323,7 @@ describe('MemoriesPanel', () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -345,7 +339,7 @@ describe('MemoriesPanel', () => {
|
||||
let putCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
@@ -363,7 +357,7 @@ describe('MemoriesPanel', () => {
|
||||
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -378,7 +372,7 @@ describe('MemoriesPanel', () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
@@ -396,7 +390,7 @@ describe('MemoriesPanel', () => {
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -407,7 +401,7 @@ describe('MemoriesPanel', () => {
|
||||
// The remove button is the second action button in the hover overlay — no title, just an X icon
|
||||
// Get all buttons and click the one after the share toggle
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
|
||||
const shareIdx = allBtns.findIndex((b) => b.getAttribute('title') === 'Stop sharing');
|
||||
// The remove button immediately follows the share button in the DOM
|
||||
await userEvent.click(allBtns[shareIdx + 1]);
|
||||
|
||||
@@ -419,11 +413,9 @@ describe('MemoriesPanel', () => {
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
|
||||
],
|
||||
assets: [{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' }],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -443,11 +435,9 @@ describe('MemoriesPanel', () => {
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [
|
||||
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
|
||||
],
|
||||
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 }],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -470,15 +460,13 @@ describe('MemoriesPanel', () => {
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
|
||||
),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [immichAddon, immich2Addon] })),
|
||||
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
|
||||
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
|
||||
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -497,15 +485,33 @@ describe('MemoriesPanel', () => {
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
{
|
||||
photo_id: 10,
|
||||
asset_id: 'p1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-01T00:00:00Z',
|
||||
city: 'Paris',
|
||||
},
|
||||
{
|
||||
photo_id: 11,
|
||||
asset_id: 'p2',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T00:00:00Z',
|
||||
city: 'Lyon',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -522,15 +528,13 @@ describe('MemoriesPanel', () => {
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
assets: [{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
addPhotosCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -569,7 +573,7 @@ describe('MemoriesPanel', () => {
|
||||
http.post('/api/integrations/memories/immich/search', () => {
|
||||
searchCount++;
|
||||
return HttpResponse.json({ assets: [] });
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -589,9 +593,7 @@ describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
|
||||
@@ -612,17 +614,11 @@ describe('MemoriesPanel', () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
|
||||
],
|
||||
addons: [{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} }],
|
||||
})
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -633,7 +629,7 @@ describe('MemoriesPanel', () => {
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
@@ -650,11 +646,9 @@ describe('MemoriesPanel', () => {
|
||||
),
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
assets: [{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -672,15 +666,33 @@ describe('MemoriesPanel', () => {
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
{
|
||||
photo_id: 10,
|
||||
asset_id: 'p1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-01T00:00:00Z',
|
||||
city: 'Paris',
|
||||
},
|
||||
{
|
||||
photo_id: 11,
|
||||
asset_id: 'p2',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T00:00:00Z',
|
||||
city: 'Lyon',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -693,9 +705,9 @@ describe('MemoriesPanel', () => {
|
||||
expect(select).toHaveValue('Paris');
|
||||
});
|
||||
|
||||
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
|
||||
it('FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
@@ -711,7 +723,7 @@ describe('MemoriesPanel', () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -731,7 +743,7 @@ describe('MemoriesPanel', () => {
|
||||
let albumLinked = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
...connectedHandlers.filter((h) => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
|
||||
@@ -746,12 +758,23 @@ describe('MemoriesPanel', () => {
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
|
||||
if (!albumLinked) return HttpResponse.json({ links: [] });
|
||||
return HttpResponse.json({
|
||||
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'album1',
|
||||
album_name: 'Summer 2025',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
|
||||
HttpResponse.json({ ok: true })
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
@@ -769,9 +792,7 @@ describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [] }))
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { buildSettings, buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import InAppNotificationItem from './InAppNotificationItem';
|
||||
|
||||
const buildNotification = (overrides = {}) => ({
|
||||
@@ -102,9 +102,7 @@ describe('InAppNotificationItem', () => {
|
||||
|
||||
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
|
||||
render(
|
||||
<InAppNotificationItem
|
||||
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
|
||||
/>
|
||||
<InAppNotificationItem notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })} />
|
||||
);
|
||||
expect(document.querySelector('img')).toBeInTheDocument();
|
||||
expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
|
||||
@@ -173,8 +171,9 @@ describe('InAppNotificationItem', () => {
|
||||
/>
|
||||
);
|
||||
// t('notifications.title') = "Notifications" — the navigate button renders this
|
||||
const navigateBtn = document.querySelector('button[style*="pointer"]') ??
|
||||
Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
|
||||
const navigateBtn =
|
||||
document.querySelector('button[style*="pointer"]') ??
|
||||
Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('Notifications'));
|
||||
expect(navigateBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -196,9 +195,7 @@ describe('InAppNotificationItem', () => {
|
||||
/>
|
||||
);
|
||||
// The navigate button renders t('notifications.title') = "Notifications"
|
||||
const btn = Array.from(document.querySelectorAll('button')).find(
|
||||
b => b.textContent?.includes('Notifications')
|
||||
);
|
||||
const btn = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('Notifications'));
|
||||
expect(btn).toBeTruthy();
|
||||
await user.click(btn!);
|
||||
expect(markRead).toHaveBeenCalledWith(77);
|
||||
|
||||
@@ -1,171 +1,194 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { ArrowRight, Check, CheckCheck, Trash2, User, X } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { InAppNotification, useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d`
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now';
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
notification: InAppNotification;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const [responding, setResponding] = useState(false)
|
||||
const { t, locale } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { settings } = useSettingsStore();
|
||||
const darkMode = settings.dark_mode;
|
||||
const dark =
|
||||
darkMode === true ||
|
||||
darkMode === 'dark' ||
|
||||
(darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const [responding, setResponding] = useState(false);
|
||||
|
||||
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore()
|
||||
const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore();
|
||||
|
||||
const handleNavigate = async () => {
|
||||
if (!notification.is_read) await markRead(notification.id)
|
||||
if (!notification.is_read) await markRead(notification.id);
|
||||
if (notification.navigate_target) {
|
||||
navigate(notification.navigate_target)
|
||||
onClose?.()
|
||||
navigate(notification.navigate_target);
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespond = async (response: 'positive' | 'negative') => {
|
||||
if (responding || notification.response !== null) return
|
||||
setResponding(true)
|
||||
await respondToBoolean(notification.id, response)
|
||||
setResponding(false)
|
||||
}
|
||||
if (responding || notification.response !== null) return;
|
||||
setResponding(true);
|
||||
await respondToBoolean(notification.id, response);
|
||||
setResponding(false);
|
||||
};
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
const titleText = t(notification.title_key, notification.title_params);
|
||||
const bodyText = t(notification.text_key, notification.text_params);
|
||||
const hasUnknownTitle = titleText === notification.title_key;
|
||||
const hasUnknownBody = bodyText === notification.text_key;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative px-4 py-3 transition-colors"
|
||||
style={{
|
||||
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
|
||||
background: notification.is_read ? 'transparent' : dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)',
|
||||
borderBottom: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img
|
||||
src={notification.sender_avatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
<img src={notification.sender_avatar} alt="" className="h-8 w-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />
|
||||
}
|
||||
{notification.sender_username ? (
|
||||
notification.sender_username.charAt(0).toUpperCase()
|
||||
) : (
|
||||
<User className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
|
||||
<div className="flex flex-shrink-0 items-center gap-0.5">
|
||||
<span className="mr-1 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
<button
|
||||
onClick={() => markRead(notification.id)}
|
||||
title={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors"
|
||||
className="rounded p-1 transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-hover)';
|
||||
e.currentTarget.style.color = 'var(--text-primary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--text-faint)';
|
||||
}}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
className="p-1 rounded transition-colors"
|
||||
className="rounded p-1 transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239,68,68,0.1)';
|
||||
e.currentTarget.style.color = '#ef4444';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--text-faint)';
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="mt-0.5 text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'positive'
|
||||
? 'var(--text-primary)'
|
||||
: notification.response === 'negative'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'positive'
|
||||
? '#fff'
|
||||
: notification.response === 'negative'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
background:
|
||||
notification.response === 'positive'
|
||||
? 'var(--text-primary)'
|
||||
: notification.response === 'negative'
|
||||
? dark
|
||||
? '#27272a'
|
||||
: '#f1f5f9'
|
||||
: dark
|
||||
? '#27272a'
|
||||
: '#f1f5f9',
|
||||
color:
|
||||
notification.response === 'positive'
|
||||
? '#fff'
|
||||
: notification.response === 'negative'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'negative' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<Check className="h-3 w-3" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || notification.response !== null}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
className="flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'negative'
|
||||
? '#ef4444'
|
||||
: notification.response === 'positive'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
color: notification.response === 'negative'
|
||||
? '#fff'
|
||||
: notification.response === 'positive'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
background:
|
||||
notification.response === 'negative'
|
||||
? '#ef4444'
|
||||
: notification.response === 'positive'
|
||||
? dark
|
||||
? '#27272a'
|
||||
: '#f1f5f9'
|
||||
: dark
|
||||
? '#27272a'
|
||||
: '#f1f5f9',
|
||||
color:
|
||||
notification.response === 'negative'
|
||||
? '#fff'
|
||||
: notification.response === 'positive'
|
||||
? 'var(--text-faint)'
|
||||
: 'var(--text-secondary)',
|
||||
opacity: notification.response === 'positive' ? 0.5 : 1,
|
||||
cursor: notification.response !== null || responding ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<X className="h-3 w-3" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -175,17 +198,17 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
<button
|
||||
onClick={handleNavigate}
|
||||
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
className="mt-2 flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9'}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = dark ? '#27272a' : '#f1f5f9')}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
{t(notification.navigate_text_key)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import ScopeGroupPicker from './ScopeGroupPicker';
|
||||
|
||||
@@ -34,9 +34,7 @@ describe('ScopeGroupPicker', () => {
|
||||
// First collect all scopes by clicking Select All and capturing the callback
|
||||
const user = userEvent.setup();
|
||||
const captured: string[][] = [];
|
||||
const { rerender } = render(
|
||||
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
|
||||
);
|
||||
const { rerender } = render(<ScopeGroupPicker selected={[]} onChange={(s) => captured.push(s)} />);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
const allScopes = captured[0];
|
||||
|
||||
@@ -50,9 +48,7 @@ describe('ScopeGroupPicker', () => {
|
||||
const captured: string[][] = [];
|
||||
|
||||
// Get all scopes first
|
||||
const { rerender } = render(
|
||||
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
|
||||
);
|
||||
const { rerender } = render(<ScopeGroupPicker selected={[]} onChange={(s) => captured.push(s)} />);
|
||||
await user.click(screen.getByRole('button', { name: /select all/i }));
|
||||
const allScopes = captured[0];
|
||||
|
||||
@@ -67,10 +63,12 @@ describe('ScopeGroupPicker', () => {
|
||||
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
|
||||
|
||||
// Groups are collapsed by default — checkboxes for individual scopes not visible
|
||||
const groupToggles = screen.getAllByRole('button').filter(b =>
|
||||
!b.textContent?.toLowerCase().includes('select all') &&
|
||||
!b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
const groupToggles = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(b) =>
|
||||
!b.textContent?.toLowerCase().includes('select all') && !b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
// Click the first group expand button
|
||||
await user.click(groupToggles[0]);
|
||||
// Individual scope checkboxes should now appear (more than just group-level ones)
|
||||
@@ -96,10 +94,12 @@ describe('ScopeGroupPicker', () => {
|
||||
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
|
||||
|
||||
// Expand first group
|
||||
const groupToggles = screen.getAllByRole('button').filter(b =>
|
||||
!b.textContent?.toLowerCase().includes('select all') &&
|
||||
!b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
const groupToggles = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(b) =>
|
||||
!b.textContent?.toLowerCase().includes('select all') && !b.textContent?.toLowerCase().includes('deselect all')
|
||||
);
|
||||
await user.click(groupToggles[0]);
|
||||
|
||||
// There are now individual scope checkboxes — click the second one (first is group-level)
|
||||
|
||||
@@ -1,64 +1,78 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { getScopesByGroup } from '../../api/oauthScopes'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { getScopesByGroup } from '../../api/oauthScopes';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface Props {
|
||||
selected: string[]
|
||||
onChange: (scopes: string[]) => void
|
||||
selected: string[];
|
||||
onChange: (scopes: string[]) => void;
|
||||
}
|
||||
|
||||
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({});
|
||||
|
||||
const scopesByGroup = getScopesByGroup(t)
|
||||
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
|
||||
const allSelected = allScopeKeys.every(s => selected.includes(s))
|
||||
const scopesByGroup = getScopesByGroup(t);
|
||||
const allScopeKeys = Object.values(scopesByGroup)
|
||||
.flat()
|
||||
.map((s) => s.scope);
|
||||
const allSelected = allScopeKeys.every((s) => selected.includes(s));
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-end mb-2">
|
||||
<div className="mb-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
|
||||
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
className="rounded border px-2 py-0.5 text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
|
||||
<div className="max-h-96 space-y-1 overflow-y-auto pr-1">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const groupScopeKeys = groupScopes.map(s => s.scope)
|
||||
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
|
||||
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
|
||||
const groupScopeKeys = groupScopes.map((s) => s.scope);
|
||||
const allGroupSelected = groupScopeKeys.every((s) => selected.includes(s));
|
||||
const someGroupSelected = groupScopeKeys.some((s) => selected.includes(s));
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div
|
||||
key={group}
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
|
||||
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
|
||||
style={{ color: 'var(--text-secondary)' }}>
|
||||
{open[group]
|
||||
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
|
||||
onClick={() => setOpen((prev) => ({ ...prev, [group]: !prev[group] }))}
|
||||
className="flex flex-1 items-center gap-1 text-left text-xs font-semibold transition-opacity hover:opacity-70"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{open[group] ? (
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
{group}
|
||||
{someGroupSelected && (
|
||||
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
|
||||
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
|
||||
({groupScopeKeys.filter((s) => selected.includes(s)).length}/{groupScopeKeys.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={e => onChange(
|
||||
e.target.checked
|
||||
? [...new Set([...selected, ...groupScopeKeys])]
|
||||
: selected.filter(s => !groupScopeKeys.includes(s))
|
||||
)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someGroupSelected && !allGroupSelected;
|
||||
}}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.checked
|
||||
? [...new Set([...selected, ...groupScopeKeys])]
|
||||
: selected.filter((s) => !groupScopeKeys.includes(s))
|
||||
)
|
||||
}
|
||||
className="rounded"
|
||||
title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
|
||||
/>
|
||||
@@ -68,29 +82,32 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
|
||||
{groupScopes.map(({ scope, label, description }) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
className="flex cursor-pointer items-start gap-2.5 px-3 py-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(scope)}
|
||||
onChange={e => onChange(
|
||||
e.target.checked
|
||||
? [...selected, scope]
|
||||
: selected.filter(s => s !== scope)
|
||||
)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
onChange={(e) =>
|
||||
onChange(e.target.checked ? [...selected, scope] : selected.filter((s) => s !== scope))
|
||||
}
|
||||
className="mt-0.5 flex-shrink-0 rounded"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user