mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76c8c349b1 | |||
| 305b113d5f | |||
| 942e1932c9 | |||
| 999236f944 | |||
| 875f87b885 | |||
| a23a81f35b | |||
| 331954c1b0 | |||
| 2d6c8c5157 | |||
| da92581a16 | |||
| 8b88986ca8 | |||
| 9094f05604 | |||
| 5e78069223 | |||
| df139a2d96 | |||
| ec3017f9cc | |||
| e753603ec8 | |||
| 6a5f067663 | |||
| 0ce82b811b | |||
| 9e13791239 | |||
| f574e75dbe | |||
| f76ee87336 | |||
| bb0d65bb19 | |||
| 7aead0b713 | |||
| d0cc4e2f00 | |||
| a5e468829c | |||
| 9522e4ce4a | |||
| 68bf5316ad | |||
| 8f171453d3 | |||
| eae8480728 | |||
| 736599a89b | |||
| 7164f4fb11 | |||
| 503a03792e | |||
| 2c333cb643 | |||
| 06b7f3e690 | |||
| 97f86dcf46 | |||
| 6e4fda897f | |||
| 56b4509b03 | |||
| 11686be264 | |||
| e809808572 | |||
| 97220b72f6 | |||
| d2cf694882 | |||
| 359c3afc21 | |||
| 6185cf96b6 | |||
| 3e4b36ac71 | |||
| 85794a5a59 | |||
| edb487fbef | |||
| 630e8efca1 | |||
| b267771772 | |||
| a3d1fc7808 | |||
| f4ee06e931 | |||
| 28765e220b | |||
| c9278c02ce | |||
| 3011bf50b4 | |||
| 408e39b9d3 | |||
| 02f7320c90 | |||
| e375f9e002 | |||
| 03f3c5ae36 | |||
| 55aef8d55c | |||
| 412de9df4d | |||
| e62e76713f | |||
| 1f92d47452 | |||
| 299c97ac9f | |||
| 1f3567d928 | |||
| 5440e76223 | |||
| 824834c8fe | |||
| 92533afea3 | |||
| 84d9fbff3c | |||
| 1e166ab0d9 | |||
| 0a7e460253 | |||
| d156404b84 | |||
| 54693474f1 | |||
| 1a0834b866 | |||
| c9124c9b63 | |||
| 244eb56e4c | |||
| 0337ebbbd2 | |||
| 1eb29f8b77 | |||
| 5bc041cc77 | |||
| c8607ba234 | |||
| 0f0bf50cd3 | |||
| ff27624e02 | |||
| 688eea23a4 | |||
| b1e4c830b2 | |||
| 2bffee8fd4 | |||
| 72221a2e45 | |||
| 61e03fdfde | |||
| cb41d082be | |||
| a6c27aac51 | |||
| f2329c9a63 | |||
| 887e6c5026 | |||
| 7c378c574f | |||
| 05315ef7d9 | |||
| 3a0604b3f5 | |||
| bfb3bcd63c | |||
| c9193e0621 | |||
| f02a4b5654 | |||
| f23de88b74 | |||
| 3e52695124 | |||
| 4cab17a6b9 | |||
| 0e7c516279 | |||
| 681fe1fee3 | |||
| f43b56a745 | |||
| 79c460fd38 | |||
| 97c725ea14 | |||
| 974c240043 | |||
| 460b215e69 | |||
| a234653b29 | |||
| 8065af28ef |
@@ -15,6 +15,8 @@ uploads
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
docker-compose.minio-test.yml
|
||||
minio-licence.key
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*.sh text eol=lf
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.webp binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.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 -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
+73
-45
@@ -10,7 +10,6 @@ on:
|
||||
- 'server/**'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- 'nest-mcp/**'
|
||||
- 'plugin-sdk/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
@@ -74,41 +73,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cd shared && npm test
|
||||
|
||||
nest-mcp-package:
|
||||
name: nest-mcp Package
|
||||
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 nest-mcp
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# Same lockfile quirk as server-tests: the Linux native binary is
|
||||
# omitted, and nest-mcp's vitest config uses the SWC transform too.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
|
||||
- name: Build
|
||||
run: npm run build --workspace=nest-mcp
|
||||
|
||||
- name: Typecheck
|
||||
run: cd nest-mcp && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd nest-mcp && npm run lint:check
|
||||
|
||||
- name: Run tests
|
||||
run: cd nest-mcp && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -137,19 +101,14 @@ jobs:
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Build nest-mcp
|
||||
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
|
||||
# (tests alias the package source, but tsc does not).
|
||||
run: npm run build --workspace=nest-mcp
|
||||
|
||||
- name: Build server (tsc -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
- name: Smoke production require chain
|
||||
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
|
||||
# what production runs: nest-mcp's built dist resolving the MCP SDK's
|
||||
# subpath exports through the tsconfig-paths/register runtime hook.
|
||||
run: cd server && node --require tsconfig-paths/register -e "require('@trek/nest-mcp')"
|
||||
# Vitest aliases the MCP SDK deep imports, so only this exercises what
|
||||
# production runs: the built dist resolving the MCP SDK's subpath
|
||||
# exports through the tsconfig-paths/register runtime hook.
|
||||
run: cd server && node --require tsconfig-paths/register -e "require('./dist/nest-mcp')"
|
||||
|
||||
- name: Typecheck
|
||||
run: cd server && npm run typecheck
|
||||
@@ -175,6 +134,75 @@ jobs:
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
|
||||
s3-contract:
|
||||
name: S3 Driver Contract (MinIO)
|
||||
runs-on: ubuntu-latest
|
||||
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty
|
||||
# and the AIStor container would fail to boot.
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
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
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||
# optional native binary, so npm ci/install skips it on the runner.
|
||||
# Install the matching version explicitly so the server's SWC transform
|
||||
# (server/vitest.config.ts) can load.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Write MinIO license
|
||||
run: printf '%s' "${{ secrets.MINIO_LICENSE_KEY }}" > minio-licence.key
|
||||
|
||||
- name: Start MinIO
|
||||
# `services:` can't pass the `server /data --license ...` command to
|
||||
# the AIStor image, so it starts via docker run. 127.0.0.1 endpoint is
|
||||
# load-bearing: aws-lite picks path-style only for IP/localhost hosts,
|
||||
# which is all stock MinIO/AIStor speaks.
|
||||
run: |
|
||||
docker run -d --name minio -p 127.0.0.1:9000:9000 \
|
||||
-v "$PWD/minio-licence.key:/minio.license:ro" \
|
||||
-e MINIO_ROOT_USER=trekci -e MINIO_ROOT_PASSWORD=trekci-secret \
|
||||
quay.io/minio/aistor/minio:RELEASE.2026-08-07T18-34-35Z server /data --license /minio.license
|
||||
|
||||
- name: Wait for MinIO
|
||||
# curl was removed from the AIStor image — poll from the runner instead.
|
||||
# 60 x 2s gives a wider margin on cold runners than the 30 x 1s the
|
||||
# pre-6fbff835 job used.
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf http://127.0.0.1:9000/minio/health/live && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "MinIO never became healthy" >&2
|
||||
docker logs minio >&2
|
||||
exit 1
|
||||
|
||||
- name: Run the S3 driver contract suite
|
||||
# No --coverage: the src/nest/storage/** 94/84/97/94 pin must keep
|
||||
# holding from hermetic tests alone. The suite's beforeAll creates
|
||||
# the trek-test bucket idempotently — no separate bucket-init step.
|
||||
env:
|
||||
TREK_TEST_S3_ENDPOINT: http://127.0.0.1:9000
|
||||
TREK_TEST_S3_BUCKET: trek-test
|
||||
TREK_TEST_S3_ACCESS_KEY_ID: trekci
|
||||
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
|
||||
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
|
||||
|
||||
client-quality:
|
||||
# Split out of client-tests: the suite takes ~11 minutes, and having the
|
||||
# gates in front of it meant a single lint finding threw away the whole test
|
||||
|
||||
@@ -65,5 +65,8 @@ coverage-*/
|
||||
.scannerwork
|
||||
test-data
|
||||
|
||||
# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key
|
||||
minio-licence.key
|
||||
|
||||
.run
|
||||
.full-review
|
||||
|
||||
@@ -31,12 +31,9 @@ FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY nest-mcp/ ./nest-mcp/
|
||||
RUN npm run build --workspace=nest-mcp
|
||||
COPY server/ ./server/
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
@@ -47,7 +44,6 @@ WORKDIR /app
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# The trailing chown runs in this layer on purpose: it covers the manifests and
|
||||
@@ -94,10 +90,6 @@ COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/mig
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
|
||||
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
|
||||
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
|
||||
# hook the server already boots with.
|
||||
COPY --chown=node:node --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
|
||||
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
|
||||
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
|
||||
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<br />
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
|
||||
</picture>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
@@ -41,61 +40,62 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/liketrek/TREK-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
<picture>
|
||||
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
|
||||
</picture>
|
||||
|
||||
<details>
|
||||
<summary><b>See all features</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧭 Trip planning
|
||||
#### 🧭 Planning
|
||||
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
- **Category filter** — show only matching pins on the map
|
||||
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
|
||||
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
|
||||
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
|
||||
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
|
||||
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
|
||||
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
|
||||
- **Public transport**: door-to-door itineraries over Transitous
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
|
||||
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
|
||||
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Travel management
|
||||
#### 🧳 Bookings and money
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
|
||||
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
|
||||
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
|
||||
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
|
||||
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
|
||||
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
|
||||
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
|
||||
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -104,64 +104,71 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||
- **Multi-user trips** — invite members with role-based access
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
|
||||
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
|
||||
- **Public share**: a read-only trip page anyone can open without an account
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
|
||||
|
||||
#### 📔 Journal, Atlas and Vacay
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
|
||||
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
|
||||
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 📱 Mobile & PWA
|
||||
#### 🧩 Plugins
|
||||
|
||||
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
|
||||
|
||||
#### 🤖 AI and MCP
|
||||
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
|
||||
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
|
||||
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
|
||||
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
|
||||
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
|
||||
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
#### 📱 Mobile and offline
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
|
||||
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
|
||||
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
|
||||
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI / MCP
|
||||
#### ⚙️ Admin, accounts and security
|
||||
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
|
||||
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
|
||||
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
|
||||
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
|
||||
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
|
||||
- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -171,6 +178,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## AI usage
|
||||
|
||||
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
|
||||
@@ -195,7 +210,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
<div align="center">
|
||||
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
<a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -462,6 +477,9 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` |
|
||||
| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — |
|
||||
| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
@@ -471,10 +489,113 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="storage">Storage</h2>
|
||||
|
||||
TREK separates *what* it stores (eight content categories — trip documents,
|
||||
journey photos, cover images, profile pictures, place images, the two photo
|
||||
caches, and backups) from *where* it stores it (named backends). Out of the
|
||||
box everything lives on local disk (`uploads/` and `data/backups`). From
|
||||
**Admin → Storage** you can:
|
||||
|
||||
- **Add S3-compatible backends** (AWS S3, Cloudflare R2, Backblaze B2, Garage,
|
||||
MinIO/AIStor) and assign any category to them.
|
||||
- **Replicate a backend**: edit it and pick **Mirror targets** — every write is
|
||||
then also copied to each target. The classic off-site backup setup is:
|
||||
add an S3 backend, edit `backups-local`, tick the S3 backend as a mirror
|
||||
target, save. Replica writes happen one after another during each upload, so
|
||||
a slow or unreachable target slows every upload of every category on that
|
||||
backend — fine for backups, worth weighing for hot categories. Existing
|
||||
objects are not replicated when you add a target — press **Sync now** on
|
||||
the backend to copy them (one sync at a time; progress and cancel in the
|
||||
panel).
|
||||
- **Watch the Health strip**: replica failures never fail the original
|
||||
request; they are recorded and shown there instead. All-clear means every
|
||||
replicated write landed. Failures also notify admins over the configured
|
||||
notification channels (first failure immediately, then hourly summaries) —
|
||||
tune it under Admin → Notifications.
|
||||
- **Test** any backend from its row (for a replicated backend it probes the
|
||||
primary and each target individually). Targets must be saved before Test
|
||||
can probe them.
|
||||
- **Usage**: per-category and per-backend object counts and sizes, scanned
|
||||
nightly and on demand.
|
||||
|
||||
Backend credentials are encrypted at rest. Without an explicit
|
||||
`ENCRYPTION_KEY` the implicit key from the data directory is used — that
|
||||
works, but the implicit key rides inside backups, so setting `ENCRYPTION_KEY`
|
||||
explicitly is recommended for credentialed backends. On an existing install
|
||||
that has been running without an explicit key, do not just set a new one —
|
||||
that would orphan already-stored secrets; use the key-rotation procedure (see
|
||||
*Rotating the Encryption Key* under Updating) to move to an explicit key.
|
||||
|
||||
Notes and limits: reassigning a populated category prompts to move its
|
||||
existing objects — accepting copies everything to the new backend, flips the
|
||||
category, then sweeps any writes that raced the copy; source objects are kept
|
||||
(not deleted) and reported as reclaimable (count + size) to reclaim manually;
|
||||
declining routes only new writes to the new backend, leaving old objects in
|
||||
place as before. Only one storage job (a mirror sync or a category migration)
|
||||
runs at a time, cancelling before the flip is always safe, and a failed copy
|
||||
never flips the category; media categories on S3 work but every served byte
|
||||
proxies through the server — the proxy answers conditional GETs (ETag /
|
||||
Last-Modified) and a single `Range` request, while a multi-range request is
|
||||
answered with the full object; AWS buckets
|
||||
with Object-Lock or checksum-requiring policies
|
||||
reject the server's uploads; self-hosted endpoints (MinIO/Garage) should be
|
||||
addressed by IP or `localhost` unless configured for virtual-hosted buckets.
|
||||
The legacy `/uploads/photos` directory from older TREK versions is still
|
||||
served and included in backups, but it is not a configurable category.
|
||||
Running the storage contract suite against a live MinIO/AIStor is a
|
||||
license-gated procedure — CI runs it automatically for same-repo pull
|
||||
requests (fork PRs skip it, since they have no access to the license
|
||||
secret); to run it yourself locally, see `docker-compose.minio-test.yml`'s
|
||||
header for the manual procedure.
|
||||
|
||||
### Provisioning at first boot (seed file)
|
||||
|
||||
For infrastructure-as-code setups, mount a `storage-config.json` into the
|
||||
data directory — it is imported **once**, on the first boot that has no
|
||||
stored storage configuration, and loudly ignored afterwards:
|
||||
|
||||
```jsonc
|
||||
// storage-config.json — secrets may be plaintext (encrypted on import)
|
||||
// or already-encrypted enc:v1: values.
|
||||
{
|
||||
"backends": [
|
||||
{ "name": "off-site", "type": "s3", "options": {
|
||||
"endpoint": "https://s3.example.com", "bucket": "trek",
|
||||
"accessKeyId": "…", "secretAccessKey": "…" } },
|
||||
{ "name": "backups-mirror", "type": "mirror", "options": {
|
||||
"primary": "backups-local", "replicas": ["off-site"] } }
|
||||
],
|
||||
"categories": { "backups": "backups-mirror" }
|
||||
}
|
||||
```
|
||||
|
||||
(The panel presents this exact setup as **Mirror targets** on `backups-local`.)
|
||||
|
||||
```yaml
|
||||
# docker-compose: add under the trek service's volumes
|
||||
- ./storage-config.json:/app/data/storage-config.json:ro
|
||||
```
|
||||
|
||||
An invalid seed file aborts boot with the exact validation error — an
|
||||
actively-provisioning operator sees the problem instead of a silent default.
|
||||
|
||||
### Recovery and restore
|
||||
|
||||
To reset storage configuration to the built-in defaults (or to re-import a
|
||||
seed file): stop the server, run
|
||||
`sqlite3 data/travel.db "DELETE FROM app_settings WHERE key LIKE 'storage.%';"`,
|
||||
and start it again.
|
||||
|
||||
Backups include the storage configuration (it lives in the database). If your
|
||||
only backup sits on S3 and the credentials for it sit inside that backup:
|
||||
start a fresh instance, enter the S3 credentials in **Admin → Storage** (or
|
||||
mount a seed file), then restore the backup from the Backup panel.
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/`
|
||||
- **Uploads** — stored in `./uploads/` by default; every content category can be reassigned to another backend in **Admin → Storage**
|
||||
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups** — create and restore via Admin Panel
|
||||
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||
|
||||
@@ -29,8 +29,13 @@
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Country silhouettes for TREK Studio, built from the boundaries the server
|
||||
* already bundles for Atlas.
|
||||
*
|
||||
* node scripts/build-country-shapes.mjs
|
||||
*
|
||||
* Writes `src/components/Studio/countryShapes.ts`. Run it when
|
||||
* `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so
|
||||
* a normal build never runs this.
|
||||
*
|
||||
* ── Why a second, tiny copy of the boundaries ─────────────────────────────
|
||||
*
|
||||
* Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy
|
||||
* map you can zoom into. A book page draws a country at 20mm across, where the
|
||||
* ten thousand points describing a fjord land inside one printer dot. Shipping
|
||||
* the map data to draw a stamp would cost more than the entire Studio bundle.
|
||||
*
|
||||
* So: project, drop the islands nobody can see at that size, simplify hard, and
|
||||
* normalise each country into its own 0..100 box. What comes out is a few
|
||||
* hundred bytes per country and prints identically, because at 20mm it *is*
|
||||
* identical.
|
||||
*
|
||||
* ── The three things that are easy to get wrong ───────────────────────────
|
||||
*
|
||||
* 1. **Project before simplifying.** Simplifying in degrees and projecting
|
||||
* afterwards removes the wrong points: a degree of longitude is 111km at the
|
||||
* equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast
|
||||
* while leaving Kenya's untouched.
|
||||
* 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at
|
||||
* +179 and -179. Taken literally their bounding box is the whole world and
|
||||
* the silhouette is a horizontal smear. Rings that span more than half the
|
||||
* globe get their negative longitudes lifted by 360 so the country is
|
||||
* contiguous again.
|
||||
* 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece
|
||||
* into static. Rings below a fraction of the largest one are dropped — the
|
||||
* mainland is what makes a country recognisable at a glance.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
const HERE = path.dirname(url.fileURLToPath(import.meta.url))
|
||||
const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz')
|
||||
const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts')
|
||||
|
||||
/** Rings smaller than this share of the biggest one are not drawn. */
|
||||
const ISLAND_CUTOFF = 0.012
|
||||
/** Never draw more than this many rings for one country. */
|
||||
const MAX_RINGS = 14
|
||||
/** Point budget per country, across every ring it keeps. */
|
||||
const MAX_POINTS = 190
|
||||
|
||||
/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */
|
||||
function project([lng, lat]) {
|
||||
const clamped = Math.max(-85.05, Math.min(85.05, lat))
|
||||
const rad = (clamped * Math.PI) / 180
|
||||
return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI]
|
||||
}
|
||||
|
||||
function ringArea(ring) {
|
||||
let a = 0
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
|
||||
}
|
||||
return Math.abs(a / 2)
|
||||
}
|
||||
|
||||
/** Perpendicular distance from p to the segment ab, squared. */
|
||||
function segDistSq(p, a, b) {
|
||||
let x = a[0], y = a[1]
|
||||
let dx = b[0] - x, dy = b[1] - y
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy)
|
||||
if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t }
|
||||
}
|
||||
dx = p[0] - x
|
||||
dy = p[1] - y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
|
||||
function simplify(points, tolerance) {
|
||||
if (points.length < 3) return points
|
||||
const tolSq = tolerance * tolerance
|
||||
const keep = new Uint8Array(points.length)
|
||||
keep[0] = 1
|
||||
keep[points.length - 1] = 1
|
||||
const stack = [[0, points.length - 1]]
|
||||
|
||||
while (stack.length) {
|
||||
const [first, last] = stack.pop()
|
||||
let maxSq = 0
|
||||
let index = 0
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
const d = segDistSq(points[i], points[first], points[last])
|
||||
if (d > maxSq) { maxSq = d; index = i }
|
||||
}
|
||||
if (maxSq > tolSq) {
|
||||
keep[index] = 1
|
||||
stack.push([first, index], [index, last])
|
||||
}
|
||||
}
|
||||
return points.filter((_, i) => keep[i])
|
||||
}
|
||||
|
||||
/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */
|
||||
function outerRings(geometry) {
|
||||
if (!geometry) return []
|
||||
if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1)
|
||||
if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0])
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a ring that straddles the antimeridian into a continuous longitude
|
||||
* range. A ring whose points span more than half the globe is, in practice,
|
||||
* always a wrap rather than a genuinely hemisphere-wide landmass.
|
||||
*/
|
||||
function unwrap(ring) {
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng }
|
||||
if (max - min <= 180) return ring
|
||||
return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat])
|
||||
}
|
||||
|
||||
function build(feature) {
|
||||
const rings = outerRings(feature.geometry)
|
||||
.map(unwrap)
|
||||
.map(ring => ring.map(project))
|
||||
.map(ring => ({ ring, area: ringArea(ring) }))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
|
||||
if (!rings.length) return null
|
||||
|
||||
const biggest = rings[0].area
|
||||
const kept = rings
|
||||
.filter(r => r.area >= biggest * ISLAND_CUTOFF)
|
||||
.slice(0, MAX_RINGS)
|
||||
.map(r => r.ring)
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
for (const ring of kept) {
|
||||
for (const [x, y] of ring) {
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
if (y > maxY) maxY = y
|
||||
}
|
||||
}
|
||||
const spanX = maxX - minX
|
||||
const spanY = maxY - minY
|
||||
if (!(spanX > 0) || !(spanY > 0)) return null
|
||||
|
||||
// The longer side becomes 100 and the shorter one keeps its proportion, so a
|
||||
// country never has to be squashed to fit the box it is drawn in.
|
||||
const scale = 100 / Math.max(spanX, spanY)
|
||||
const w = spanX * scale
|
||||
const h = spanY * scale
|
||||
const normalised = kept.map(ring =>
|
||||
ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale]))
|
||||
|
||||
// Tolerance climbs until the whole country fits its point budget. Starting
|
||||
// from the diagonal keeps the loop short for a country of any size.
|
||||
let tolerance = 0.14
|
||||
let simplified = normalised.map(r => simplify(r, tolerance))
|
||||
let guard = 0
|
||||
while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) {
|
||||
tolerance *= 1.35
|
||||
simplified = normalised.map(r => simplify(r, tolerance))
|
||||
}
|
||||
|
||||
const d = simplified
|
||||
.filter(ring => ring.length >= 3)
|
||||
.map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`)
|
||||
.join('')
|
||||
|
||||
// The projected bounds, kept so the same path can be replayed in world
|
||||
// coordinates. A country drawn on its own wants its own box; the same country
|
||||
// on a route map has to sit where it actually is, next to its neighbours. One
|
||||
// normalised path plus these four numbers serves both, where storing two sets
|
||||
// of coordinates would be twice the bytes and one more thing to disagree.
|
||||
return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null
|
||||
}
|
||||
|
||||
const round = n => Math.round(n * 10) / 10
|
||||
|
||||
const raw = zlib.gunzipSync(fs.readFileSync(SRC))
|
||||
const collection = JSON.parse(raw.toString('utf8'))
|
||||
|
||||
const shapes = new Map()
|
||||
for (const feature of collection.features) {
|
||||
const code = String(feature.properties?.ISO_A2 || '').toUpperCase()
|
||||
if (code.length !== 2 || code === '-9') continue
|
||||
const built = build(feature)
|
||||
if (!built) continue
|
||||
// A code appearing twice means two features for one country; keep the one
|
||||
// with more outline, which is the mainland rather than a dependency.
|
||||
const prev = shapes.get(code)
|
||||
if (!prev || built.d.length > prev.d.length) shapes.set(code, built)
|
||||
}
|
||||
|
||||
const codes = [...shapes.keys()].sort()
|
||||
const body = codes
|
||||
.map(code => {
|
||||
const s = shapes.get(code)
|
||||
return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const file = `/**
|
||||
* GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`.
|
||||
*
|
||||
* Country silhouettes, simplified from the admin-0 boundaries the server
|
||||
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
|
||||
* normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's
|
||||
* own proportions inside that box so it can be drawn without being squashed.
|
||||
*
|
||||
* ${codes.length} countries, built for print at postage-stamp size — islands
|
||||
* below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is
|
||||
* simplified to at most ${MAX_POINTS} points.
|
||||
*/
|
||||
|
||||
export interface CountryShape {
|
||||
/** Path on a 0..100 box, outer rings only. */
|
||||
d: string
|
||||
/** The shape's own width and height inside that box. */
|
||||
w: number
|
||||
h: number
|
||||
/** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */
|
||||
b: [number, number, number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Mercator, matching the projection the paths were built with. Longitude
|
||||
* passes through as degrees; latitude is flipped so y grows downwards like
|
||||
* SVG's does.
|
||||
*/
|
||||
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
|
||||
const clamped = Math.max(-85.05, Math.min(85.05, lat))
|
||||
const rad = (clamped * Math.PI) / 180
|
||||
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
|
||||
}
|
||||
|
||||
/**
|
||||
* The same outline, placed in projected world coordinates.
|
||||
*
|
||||
* The stored path is normalised into its own box; multiplying by the box's span
|
||||
* and shifting by its origin puts it back where the country actually is, which
|
||||
* is what a map with more than one country on it needs.
|
||||
*/
|
||||
export function countryWorldPath(shape: CountryShape): string {
|
||||
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
|
||||
let axis = 0
|
||||
return shape.d.replace(/-?\\d*\\.?\\d+/g, m => {
|
||||
const isX = axis++ % 2 === 0
|
||||
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
|
||||
return String(Math.round(v * 1000) / 1000)
|
||||
})
|
||||
}
|
||||
|
||||
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
|
||||
${body}
|
||||
}
|
||||
|
||||
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
|
||||
export function countryShape(code: string | null | undefined): CountryShape | null {
|
||||
if (!code) return null
|
||||
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync(OUT, file, 'utf8')
|
||||
const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10
|
||||
console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`)
|
||||
@@ -387,6 +387,30 @@ describe('App — on-mount effects', () => {
|
||||
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true }))
|
||||
)
|
||||
const setManaged = vi.fn()
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
setManaged,
|
||||
})
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true))
|
||||
|
||||
// An older server that does not send the field must not leave the flag
|
||||
// stuck on from a previous install in the same browser profile.
|
||||
setManaged.mockClear()
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({}))
|
||||
)
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
|
||||
})
|
||||
|
||||
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() })
|
||||
|
||||
+20
-3
@@ -28,6 +28,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
import { managedRoutes } from './managed'
|
||||
|
||||
// Every page below loads on demand. The entry chunk used to carry all twenty of
|
||||
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
|
||||
@@ -47,6 +48,7 @@ const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
|
||||
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
|
||||
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
|
||||
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
|
||||
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
|
||||
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
|
||||
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
|
||||
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
|
||||
@@ -278,7 +280,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -295,7 +297,8 @@ export default function App() {
|
||||
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; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; 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; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
@@ -471,6 +474,15 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Empty in this repository, and read unconditionally so the public
|
||||
build walks the same path as any other. See client/src/managed. */}
|
||||
{managedRoutes.map(r => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={r.path}
|
||||
element={<ProtectedRoute adminRequired={r.adminOnly}>{r.element}</ProtectedRoute>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
@@ -502,7 +514,12 @@ export default function App() {
|
||||
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
>
|
||||
{/* Studio is nested so the journey stays mounted underneath it and
|
||||
shows through the panel's margin — it is opened on top of the
|
||||
journey, not navigated away to. */}
|
||||
<Route path="studio" element={<JourneyStudioPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={
|
||||
|
||||
@@ -267,6 +267,14 @@ describe('client > endpoint wiring', () => {
|
||||
{ n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' },
|
||||
{ n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' },
|
||||
{ n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' },
|
||||
{ n: 'getStorage', r: () => adminApi.getStorage(), e: 'GET /api/admin/storage' },
|
||||
{ n: 'updateStorage', r: () => adminApi.updateStorage({ backends: [], categories: {}, version: 0 }), e: 'PUT /api/admin/storage' },
|
||||
{ n: 'testStorageBackend', r: () => adminApi.testStorageBackend({ name: 'x', type: 'local', options: { root: '/data' } }), e: 'POST /api/admin/storage/test' },
|
||||
{ n: 'startStorageBackfill', r: () => adminApi.startStorageBackfill('m'), e: 'POST /api/admin/storage/backends/m/backfill' },
|
||||
{ n: 'cancelStorageBackfill', r: () => adminApi.cancelStorageBackfill('m'), e: 'DELETE /api/admin/storage/backends/m/backfill' },
|
||||
{ n: 'startStorageMigration', r: () => adminApi.startStorageMigration('files', 'dest'), e: 'POST /api/admin/storage/migrations' },
|
||||
{ n: 'cancelStorageMigration', r: () => adminApi.cancelStorageMigration('files'), e: 'DELETE /api/admin/storage/migrations/files' },
|
||||
{ n: 'refreshStorageStats', r: () => adminApi.refreshStorageStats(), e: 'POST /api/admin/storage/stats/refresh' },
|
||||
{ n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' },
|
||||
{ n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' },
|
||||
{ n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' },
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type NotificationRespondRequest,
|
||||
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
|
||||
type JourneyStats, type BookRecord, type BookSaveRequest,
|
||||
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||
type JourneyShareLinkRequest,
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
@@ -46,6 +47,11 @@ import {
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
type BookingImportMode,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageConfigPut,
|
||||
type StorageTestResponse,
|
||||
type StorageUsage,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -636,6 +642,26 @@ export const adminApi = {
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||
getStorage: (): Promise<StorageAdminState> => apiClient.get('/admin/storage').then(r => r.data),
|
||||
updateStorage: (config: StorageConfigPut): Promise<StorageAdminState> =>
|
||||
apiClient.put('/admin/storage', config).then(r => r.data),
|
||||
// A probe is bounded by the target driver's own timeout (default 30s, ×2 with
|
||||
// one retry) and a mirror probes its targets sequentially — the instance-wide
|
||||
// 8s axios timeout would abort a legitimate slow probe, so this call carries
|
||||
// its own generous ceiling.
|
||||
testStorageBackend: (backend: StorageBackend): Promise<StorageTestResponse> =>
|
||||
apiClient.post('/admin/storage/test', { backend }, { timeout: 120_000 }).then(r => r.data),
|
||||
startStorageBackfill: (backend: string): Promise<{ started: true }> =>
|
||||
apiClient.post(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
|
||||
cancelStorageBackfill: (backend: string): Promise<{ cancelled: true }> =>
|
||||
apiClient.delete(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
|
||||
startStorageMigration: (category: string, to: string): Promise<{ started: true }> =>
|
||||
apiClient.post('/admin/storage/migrations', { category, to }).then(r => r.data),
|
||||
cancelStorageMigration: (category: string): Promise<{ cancelled: true }> =>
|
||||
apiClient.delete(`/admin/storage/migrations/${encodeURIComponent(category)}`).then(r => r.data),
|
||||
// A full scan of a large install can exceed the 8s instance timeout.
|
||||
refreshStorageStats: (): Promise<StorageUsage> =>
|
||||
apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -889,6 +915,20 @@ export const journeyApi = {
|
||||
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
|
||||
// GPX tracks of the trips this journey's entries came from (#1260).
|
||||
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
|
||||
// What the journey adds up to — distance, days, countries, the route (#1973).
|
||||
// Read by TREK Studio, which freezes the answer into the book document.
|
||||
stats: (id: number): Promise<JourneyStats> => apiClient.get(`/journeys/${id}/stats`).then(r => r.data),
|
||||
|
||||
// The Studio book (#1973). `book` is null for a journey that has none yet —
|
||||
// Studio lays one out and the first save creates it.
|
||||
getBook: (id: number): Promise<{ book: BookRecord | null }> =>
|
||||
apiClient.get(`/journeys/${id}/book`).then(r => r.data),
|
||||
// A 409 carries the current record in its body, so a conflict can be shown
|
||||
// rather than only announced. See useBookStore.
|
||||
saveBook: (id: number, body: BookSaveRequest): Promise<BookRecord> =>
|
||||
apiClient.put(`/journeys/${id}/book`, body).then(r => r.data),
|
||||
deleteBook: (id: number): Promise<void> =>
|
||||
apiClient.delete(`/journeys/${id}/book`).then(r => r.data),
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
@@ -930,9 +970,9 @@ export const journeyApi = {
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
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 => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
@@ -1029,7 +1069,8 @@ export const healthApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
|
||||
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||
}
|
||||
|
||||
@@ -9,6 +9,15 @@ let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set<WebSocketListener>()
|
||||
const activeTrips = new Set<string>()
|
||||
|
||||
/**
|
||||
* Studio books this tab has open, so presence survives a reconnect.
|
||||
*
|
||||
* Same reasoning as `activeTrips`: the server forgets the room when the socket
|
||||
* dies, and without rejoining, everyone else keeps showing a pointer for
|
||||
* somebody who is no longer there.
|
||||
*/
|
||||
const activeBooks = new Set<string>()
|
||||
let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
@@ -110,6 +119,11 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
|
||||
socket.onopen = () => {
|
||||
reconnectDelay = 1000
|
||||
activeBooks.forEach(journeyId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'book:join', journeyId }))
|
||||
}
|
||||
})
|
||||
if (activeTrips.size > 0) {
|
||||
activeTrips.forEach(tripId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
@@ -166,6 +180,7 @@ export function disconnect(): void {
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeTrips.clear()
|
||||
activeBooks.clear()
|
||||
if (socket) {
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
@@ -187,6 +202,40 @@ export function leaveTrip(tripId: number | string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a Studio book — presence and pointers, for as long as the editor is up.
|
||||
*/
|
||||
export function joinBook(journeyId: number | string): void {
|
||||
activeBooks.add(String(journeyId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function leaveBook(journeyId: number | string): void {
|
||||
activeBooks.delete(String(journeyId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this tab's pointer is, in millimetres on the spread.
|
||||
*
|
||||
* Dropped silently when the socket is not open. A pointer is worth nothing a
|
||||
* moment later, so there is nothing to queue and nothing to retry — the next
|
||||
* one is along in a tenth of a second.
|
||||
*/
|
||||
export function sendBookCursor(
|
||||
journeyId: number | string,
|
||||
spreadIndex: number,
|
||||
x: number | null,
|
||||
y: number | null,
|
||||
): void {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y }))
|
||||
}
|
||||
|
||||
export function addListener(fn: WebSocketListener): void {
|
||||
listeners.add(fn)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'journey:entry:deleted',
|
||||
'journey:entries:reordered',
|
||||
'journey:contributor:changed',
|
||||
// Studio book — components/Studio/useBookStore (its own listener: a client
|
||||
// with nothing outstanding takes the new version, one with unsaved edits
|
||||
// deliberately does not and conflicts on its next save instead)
|
||||
'journey:book:saved',
|
||||
// Studio presence — components/Studio/useBookPresence (its own listener:
|
||||
// who has the book open, and where their pointers are)
|
||||
'journey:book:peers',
|
||||
'journey:book:cursor',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -108,6 +109,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
@@ -379,7 +381,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, FolderTree, HardDrive } from 'lucide-react'
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageMigrationStatus,
|
||||
type StorageTestResponse,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { formatBytes } from '../../../utils/formatBytes'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import Section from '../../Settings/Section'
|
||||
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
|
||||
import {
|
||||
CACHE_CATEGORIES,
|
||||
adoptedMirrorFor,
|
||||
computeMigrationCandidates,
|
||||
effectiveCategoryMap,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
settingsDocumentOf,
|
||||
setMirrorTargets,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
type FoldedBackendRow,
|
||||
type MigrationCandidate,
|
||||
} from './storageModel'
|
||||
import { useStorageAdmin } from './useStorageAdmin'
|
||||
|
||||
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
|
||||
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
ids.map((id) => t(`storage.category.${id}`)).join(', ')
|
||||
|
||||
function TestResult({ result, failedLabel, okLabel }: {
|
||||
result: StorageTestResponse | undefined
|
||||
failedLabel: string
|
||||
okLabel: string
|
||||
}): React.ReactElement | null {
|
||||
if (result === undefined) return null
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="text-xs text-content-faint">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
|
||||
|
||||
export default function AdminStoragePanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
|
||||
const [editing, setEditing] = useState<{
|
||||
initial: StorageBackend | null
|
||||
originalName: string | null
|
||||
mirror: BackendFormMirrorProps
|
||||
} | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
|
||||
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
|
||||
// Open/closed only — never a snapshot of candidates. Storing the candidate
|
||||
// array here would go stale the moment the operator edits a category while
|
||||
// the dialog is open; the render below and moveAndSave both recompute
|
||||
// computeMigrationCandidates(draft, state) fresh instead.
|
||||
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
|
||||
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
|
||||
// Set by `save` right before it calls admin.save(): the pre-save mirror
|
||||
// target count per row name. Consumed (and cleared) by the effect below the
|
||||
// first time `admin.state` changes afterward — never touched otherwise, so
|
||||
// unrelated state changes (the backfill poll included) are no-ops here.
|
||||
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
|
||||
// Synchronous single-flight lock for the queue effect below: `admin.state`
|
||||
// only reflects a just-started migration once startMigration's awaited
|
||||
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
|
||||
// effect synchronously (still against the pre-POST `admin.state`) would
|
||||
// otherwise dequeue and POST the next candidate before the server has
|
||||
// confirmed the first — a ref (not state) so the guard is visible on that
|
||||
// very next synchronous re-render, not just after a state-driven one.
|
||||
const migrationStartInFlight = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPromptCheck.current || !admin.state) return
|
||||
const before = pendingPromptCheck.current
|
||||
pendingPromptCheck.current = null
|
||||
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
|
||||
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
|
||||
if (grown) setSyncPrompt(grown.name)
|
||||
}, [admin.state])
|
||||
|
||||
// Queued category migrations run strictly sequentially: once a slot opens
|
||||
// (no migration currently running), dequeue the next candidate and start
|
||||
// it. Depends on admin.state so a poll landing a terminal status re-fires
|
||||
// this without any user interaction. The in-flight lock closes the race
|
||||
// where setMigrationQueue(rest) re-fires this effect synchronously while
|
||||
// admin.state still shows the pre-POST world — without it, the next
|
||||
// candidate could dequeue and POST before the first is server-confirmed.
|
||||
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
|
||||
// rule spans backfills and migrations alike, so starting while one runs
|
||||
// would 409, and the queued candidate would be lost (no retry).
|
||||
//
|
||||
// A failed startMigration POST would otherwise strand `rest`: it was
|
||||
// already dequeued into local state before the POST, so on failure nothing
|
||||
// ever changes `migrationQueue` or `admin.state` again — the effect never
|
||||
// re-fires and the remaining candidates sit invisibly forever, silently
|
||||
// never migrated. On failure the whole remaining queue is explicitly
|
||||
// cleared (never retried — the operator re-triggers via Save), named in a
|
||||
// toast, and admin.state is refreshed so the panel reflects whatever the
|
||||
// failed attempt's category ended up as server-side.
|
||||
useEffect(() => {
|
||||
if (migrationQueue.length === 0 || !admin.state) return
|
||||
if (migrationStartInFlight.current) return
|
||||
if (admin.state.migrations.some((m) => m.status === 'running')) return
|
||||
if (admin.state.backfills.some((b) => b.status === 'running')) return
|
||||
const [next, ...rest] = migrationQueue
|
||||
migrationStartInFlight.current = true
|
||||
setMigrationQueue(rest)
|
||||
void admin.startMigration(next!.category, next!.toWire).then((error) => {
|
||||
migrationStartInFlight.current = false
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
if (rest.length > 0) {
|
||||
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
|
||||
setMigrationQueue([])
|
||||
}
|
||||
void admin.refreshState().catch(() => {})
|
||||
}
|
||||
})
|
||||
}, [migrationQueue, admin.state])
|
||||
|
||||
if (admin.loading) {
|
||||
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
|
||||
}
|
||||
if (!admin.state || !admin.draft) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
|
||||
{admin.loadError || t('common.error')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { state, draft } = admin
|
||||
|
||||
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
|
||||
// never shows a stale candidate set while it's open (fix: migration prompt
|
||||
// staleness). moveAndSave recomputes independently at confirm time rather
|
||||
// than reading this render-scoped value, since the click handler and the
|
||||
// render that produced it are not guaranteed to be the same one.
|
||||
const migrationCandidates = computeMigrationCandidates(draft, state)
|
||||
|
||||
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
|
||||
// mirror-target candidates are the visible primaries only.
|
||||
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
|
||||
const { rows, degenerate } = foldBackends(state, draft)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const usageSums = usageByBackend(state, draft)
|
||||
|
||||
const startEdit = (row: FoldedBackendRow) => {
|
||||
// Editing a built-in creates a settings override row bearing its name —
|
||||
// the first-class relocation path (merge-by-name).
|
||||
setEditing({
|
||||
initial: row.backend,
|
||||
originalName: row.name,
|
||||
mirror: {
|
||||
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
|
||||
initialTargets: row.mirrorTargets,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
|
||||
const renamedFrom =
|
||||
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
|
||||
// Widened to StorageConfig: these edit helpers are version-blind (they
|
||||
// return plain StorageConfig), and admin.setDraft re-attaches the
|
||||
// draft's own `version` regardless of what shape it's handed.
|
||||
let next: StorageConfig | null = draft
|
||||
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
|
||||
next = upsertBackend(next, backend)
|
||||
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
|
||||
admin.setDraft(next)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const removeMessage = (name: string, isDegenerate: boolean): string => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
const assigned = isDegenerate
|
||||
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
|
||||
: row?.categories ?? []
|
||||
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
|
||||
return [
|
||||
t('storage.remove.body', { name }),
|
||||
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
|
||||
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const setCategory = (category: StorageCategory, primaryName: string) => {
|
||||
// Picking a mirrored primary routes through its adopted mirror silently.
|
||||
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const categories = { ...draft.categories }
|
||||
if (stateEntry.source === 'default' && target === stateEntry.backend) {
|
||||
delete categories[category] // back to the default → no longer settings-owned
|
||||
} else {
|
||||
categories[category] = target
|
||||
}
|
||||
admin.setDraft({ ...draft, categories })
|
||||
}
|
||||
|
||||
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
|
||||
// in-progress `draft`) mirror target counts; the effect above compares
|
||||
// them against the fresh post-save state to detect newly-added
|
||||
// replicas. Folding `draft` here would already include the unsaved
|
||||
// edit and mask the very growth this is meant to detect.
|
||||
const snapshotMirrorTargets = (): Map<string, number> => {
|
||||
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
|
||||
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
|
||||
}
|
||||
|
||||
const doPlainSave = async () => {
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
if (await admin.save()) toast.success(t('storage.saved'))
|
||||
else pendingPromptCheck.current = null
|
||||
}
|
||||
|
||||
// Recomputes the candidate set at confirm time — never trusts whatever was
|
||||
// true when the dialog opened. The operator can edit categories (or a
|
||||
// background poll can land a fresher usage scan) while the dialog is on
|
||||
// screen; the recomputed set is what actually drives both stripCategories
|
||||
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
|
||||
// so the two can never diverge from what the dialog most recently showed.
|
||||
const moveAndSave = async () => {
|
||||
const candidates = computeMigrationCandidates(draft, state)
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
|
||||
if (ok) {
|
||||
setMigrationQueue(candidates)
|
||||
toast.success(t('storage.saved'))
|
||||
} else {
|
||||
pendingPromptCheck.current = null
|
||||
}
|
||||
setMigratePromptOpen(false)
|
||||
}
|
||||
|
||||
const routeOnlySave = async () => {
|
||||
setMigratePromptOpen(false)
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (migrationCandidates.length > 0) {
|
||||
setMigratePromptOpen(true)
|
||||
return
|
||||
}
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const handleCancelMigration = async (m: StorageMigrationStatus) => {
|
||||
const error = await admin.cancelMigration(m.category)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
|
||||
|
||||
const handleRefreshStats = async () => {
|
||||
const error = await admin.refreshStats()
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleStartBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
setSyncPrompt((prev) => (prev === row.name ? null : prev))
|
||||
const error = await admin.startBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleCancelBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
const error = await admin.cancelBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.configError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3 mb-4 text-sm"
|
||||
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
</div>
|
||||
)}
|
||||
<Section title={t('storage.health.title')} icon={Activity}>
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{state.health.replicaFailures.map((failure) => (
|
||||
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.backends.title')} icon={HardDrive}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
|
||||
<p className="text-xs mb-2 text-content-faint">
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
{' '}
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
|
||||
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
|
||||
</button>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const resultKey = row.mirrorName ?? row.name
|
||||
const result = testResultFor(resultKey)
|
||||
// row.mirrorName only exists when foldBackends adopted a draft mirror
|
||||
// for this row, so the draft lookup below cannot miss.
|
||||
const testCandidate = row.mirrorName
|
||||
? draft.backends.find((b) => b.name === row.mirrorName)!
|
||||
: row.backend
|
||||
const rowUsage = usageSums?.[row.name]
|
||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||
return (
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{row.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t(`storage.type.${row.type}`)}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
{row.source !== 'env' && (
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
|
||||
{t('storage.actions.edit')}
|
||||
</button>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{rowUsage && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.done', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
|
||||
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
<button
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{degenerate.map(({ backend, reason }) => {
|
||||
const result = testResultFor(backend.name)
|
||||
const primary = backend.type === 'mirror' ? backend.options.primary : ''
|
||||
return (
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{backend.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t('storage.type.mirror')}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{state.migrations.length > 0 && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{state.migrations.map((m) => (
|
||||
<div
|
||||
key={m.category}
|
||||
data-testid={`storage-migration-${m.category}`}
|
||||
className="rounded-xl border p-4 border-edge-secondary"
|
||||
>
|
||||
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.categories.title')} icon={FolderTree}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const selectedPrimary = primaryNameOf(state, draft, effective[category])
|
||||
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
|
||||
const viaMirror = effective[category] !== selectedPrimary
|
||||
return (
|
||||
<div key={category} data-testid={`storage-category-${category}`}>
|
||||
<label className="block text-sm font-medium mb-1 text-content-secondary">
|
||||
{t(`storage.category.${category}`)}
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
|
||||
{category}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
|
||||
{state.usage?.categories[category] && (
|
||||
<p className="text-xs mb-1.5 text-content-faint">
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={selectedPrimary}
|
||||
onChange={(value) => setCategory(category, String(value))}
|
||||
options={rows.map((row) => ({
|
||||
value: row.name,
|
||||
label:
|
||||
stateEntry.source === 'default' && row.name === stateEntry.backend
|
||||
? `${row.name} (${t('storage.categories.default')})`
|
||||
: row.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
{changed && (
|
||||
<p role="alert" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
{viaMirror && CACHE_CATEGORIES.includes(category) && (
|
||||
<p role="note" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.cacheWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
|
||||
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
|
||||
</div>
|
||||
{migrationQueue.length > 0 && (
|
||||
<p className="text-xs mt-2 text-content-faint">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</p>
|
||||
)}
|
||||
{migratePromptOpen && (
|
||||
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
|
||||
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="text-xs mt-1 text-content-secondary">
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
</button>
|
||||
<button
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
>
|
||||
{t('storage.migrate.promptCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{admin.saveError && (
|
||||
<div role="alert" className="text-sm mt-2 text-content">
|
||||
<p>{admin.saveError}</p>
|
||||
{/* A 409 leaves the draft pinned to a version the server has moved
|
||||
past; "save again" can never clear it, so the banner offers the
|
||||
one action that can — see useStorageAdmin.discardDraft. */}
|
||||
{admin.saveConflict && (
|
||||
<button
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void admin.discardDraft()}
|
||||
>
|
||||
{t('storage.discardAndReload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
onConfirm={() => {
|
||||
if (confirmRemove) {
|
||||
admin.setDraft(
|
||||
confirmRemove.degenerate
|
||||
? removeBackend(draft, confirmRemove.name)
|
||||
: removeBackendAndMirrors(state, draft, confirmRemove.name),
|
||||
)
|
||||
}
|
||||
setConfirmRemove(null)
|
||||
}}
|
||||
title={t('storage.remove.title')}
|
||||
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
|
||||
confirmLabel={t('storage.remove.title')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import BackendForm from './BackendForm';
|
||||
|
||||
const NAMES = ['uploads-local', 'backups-local', 'off-box'];
|
||||
|
||||
type FormProps = Parameters<typeof BackendForm>[0];
|
||||
|
||||
function renderForm(overrides: Partial<FormProps> = {}) {
|
||||
const onCommit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<BackendForm
|
||||
initial={null}
|
||||
backendNames={NAMES}
|
||||
onCommit={onCommit}
|
||||
onCancel={onCancel}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onCommit, onCancel };
|
||||
}
|
||||
|
||||
const S3_INITIAL: StorageBackend = {
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
};
|
||||
|
||||
describe('BackendForm', () => {
|
||||
it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => {
|
||||
renderForm();
|
||||
expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text');
|
||||
expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password');
|
||||
expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1');
|
||||
expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => {
|
||||
const { onCommit } = renderForm({ initial: S3_INITIAL });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-005: a typed plaintext secret keeps Apply offered (no encryption-key gate)', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => {
|
||||
const { onCommit } = renderForm();
|
||||
const apply = screen.getByRole('button', { name: 'Apply' });
|
||||
expect(apply).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } });
|
||||
expect(apply).toBeDisabled(); // root still empty
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } });
|
||||
expect(apply).toBeEnabled();
|
||||
fireEvent.click(apply);
|
||||
expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => {
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } });
|
||||
expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => {
|
||||
const { onCommit } = renderForm();
|
||||
// Switch to s3 via the type select (CustomSelect renders options into a portal).
|
||||
fireEvent.click(screen.getByText('Local'));
|
||||
fireEvent.click(screen.getByText('S3'));
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } });
|
||||
fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } });
|
||||
fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } });
|
||||
fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } });
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
const committed = onCommit.mock.calls[0]![0] as StorageBackend;
|
||||
expect(committed.options).toEqual({
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk',
|
||||
});
|
||||
expect(committed.options).not.toHaveProperty('region');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-009: renaming onto another existing backend warns and blocks Apply', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'uploads-local' } });
|
||||
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
// Keeping the original name stays allowed.
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => {
|
||||
renderForm();
|
||||
fireEvent.click(screen.getByText('Local')); // open the type select
|
||||
expect(screen.queryByText('Mirror')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('S3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] },
|
||||
});
|
||||
expect(screen.getByText('Mirror targets')).toBeInTheDocument();
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
expect(boxes).toHaveLength(2); // off-box (self) excluded
|
||||
expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked();
|
||||
expect(screen.getByRole('note').textContent).toContain('slows every upload');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'off-box', type: 's3' }),
|
||||
['backups-local', 'uploads-local'],
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
STORAGE_BACKEND_TYPES,
|
||||
STORAGE_BACKEND_TYPE_IDS,
|
||||
type StorageBackend,
|
||||
type StorageBackendFieldDef,
|
||||
type StorageBackendTypeId,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
|
||||
type FieldValues = Record<string, string | string[]>
|
||||
|
||||
function valuesOf(backend: StorageBackend | null): FieldValues {
|
||||
if (!backend) return {}
|
||||
const values: FieldValues = {}
|
||||
for (const [key, value] of Object.entries(backend.options)) {
|
||||
values[key] = Array.isArray(value) ? value : String(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export interface BackendFormMirrorProps {
|
||||
/** Selectable replica targets (the form excludes its own name at render). */
|
||||
candidates: string[]
|
||||
initialTargets: string[]
|
||||
}
|
||||
|
||||
interface BackendFormProps {
|
||||
/** null = new backend */
|
||||
initial: StorageBackend | null
|
||||
/** Every defined backend name — mirror-target options and the duplicate pre-check. */
|
||||
backendNames: string[]
|
||||
/**
|
||||
* Present on non-mirror backends: renders the Mirror-targets composer
|
||||
* (replicas-on-primary — panel-supplied chrome, never a registry field).
|
||||
* When present, onCommit's second argument is always an array.
|
||||
*/
|
||||
mirror?: BackendFormMirrorProps
|
||||
onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
|
||||
const INPUT_CLASS =
|
||||
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
|
||||
|
||||
/**
|
||||
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
|
||||
* `mirror` type is hidden from the type select — mirrors are composed via the
|
||||
* Mirror-targets prop block and synthesized by the panels (the ref-kind
|
||||
* renderers below stay for future registry types).
|
||||
*/
|
||||
export default function BackendForm({
|
||||
initial,
|
||||
backendNames,
|
||||
mirror,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: BackendFormProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
||||
const [targets, setTargets] = useState<string[]>(mirror?.initialTargets ?? [])
|
||||
|
||||
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
|
||||
const refOptions = backendNames.filter((candidate) => candidate !== name.trim())
|
||||
const setValue = (key: string, value: string | string[]) =>
|
||||
setValues((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
const filled = (field: StorageBackendFieldDef): boolean => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim())
|
||||
const canApply =
|
||||
name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f))
|
||||
|
||||
const apply = () => {
|
||||
const options: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
options[field.key] = Array.isArray(value) ? value : []
|
||||
continue
|
||||
}
|
||||
const text = typeof value === 'string' ? value : ''
|
||||
if (text === '' && !field.required) continue // omitted → the shared schema default applies
|
||||
options[field.key] = field.kind === 'number' ? Number(text) : text
|
||||
}
|
||||
// The options were built from the same field defs the schema is generated
|
||||
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
|
||||
const payload = { name: name.trim(), type, options } as StorageBackend
|
||||
// Arity matters: the landed tests pin single-argument calls when no mirror
|
||||
// block is supplied (toHaveBeenCalledWith treats a trailing undefined as a
|
||||
// mismatch), so the second argument exists only when the composer does.
|
||||
if (mirror) onCommit(payload, targets)
|
||||
else onCommit(payload)
|
||||
}
|
||||
|
||||
const renderField = (field: StorageBackendFieldDef): React.ReactElement => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref') {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<CustomSelect
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(next) => setValue(field.key, String(next))}
|
||||
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
|
||||
placeholder={t(field.labelKey)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<div className="space-y-1">
|
||||
{refOptions.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setValue(
|
||||
field.key,
|
||||
e.target.checked
|
||||
? [...selected, candidate]
|
||||
: selected.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
|
||||
return (
|
||||
<label key={field.key} className={LABEL_CLASS}>
|
||||
{t(field.labelKey)}
|
||||
<input
|
||||
type={inputType}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setValue(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
</p>
|
||||
|
||||
<label className={LABEL_CLASS}>
|
||||
{t('storage.form.name')}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</label>
|
||||
{duplicate && (
|
||||
<p className="text-xs text-content-faint" role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.map(renderField)}
|
||||
|
||||
{mirror && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
|
||||
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
|
||||
<div className="space-y-1">
|
||||
{mirror.candidates
|
||||
.filter((candidate) => candidate !== name.trim())
|
||||
.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targets.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setTargets(
|
||||
e.target.checked
|
||||
? [...targets, candidate]
|
||||
: targets.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{targets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint" role="note">
|
||||
{t('storage.mirror.latencyNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={apply}
|
||||
disabled={!canApply}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.form.apply')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.form.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
categoriesPointingAt,
|
||||
computeMigrationCandidates,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
mirrorsReferencing,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
setMirrorTargets,
|
||||
settingsDocumentOf,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
} from './storageModel';
|
||||
|
||||
const STATE: StorageAdminState = {
|
||||
backends: [
|
||||
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
|
||||
{ name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] },
|
||||
{
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
source: 'settings',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
categories: ['covers'],
|
||||
},
|
||||
],
|
||||
categories: {
|
||||
files: { backend: 'uploads-local', source: 'default' },
|
||||
journey: { backend: 'uploads-local', source: 'default' },
|
||||
covers: { backend: 'off-box', source: 'settings' },
|
||||
avatars: { backend: 'uploads-local', source: 'default' },
|
||||
places: { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-google': { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-trek': { backend: 'uploads-local', source: 'default' },
|
||||
backups: { backend: 'backups-local', source: 'default' },
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
version: 5,
|
||||
configError: null,
|
||||
};
|
||||
|
||||
describe('settingsDocumentOf', () => {
|
||||
it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => {
|
||||
const doc = settingsDocumentOf(STATE);
|
||||
expect(doc.backends.map((b) => b.name)).toEqual(['off-box']);
|
||||
expect(doc.categories).toEqual({ covers: 'off-box' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-001b: carries version from state (audit #7 — the PUT body\'s optimistic-concurrency check)', () => {
|
||||
expect(settingsDocumentOf(STATE).version).toBe(5);
|
||||
expect(settingsDocumentOf({ ...STATE, version: 12 }).version).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove pre-check helpers', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas', backups: 'mir' },
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => {
|
||||
expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']);
|
||||
expect(categoriesPointingAt(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => {
|
||||
expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('draft edits', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
|
||||
categories: {},
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => {
|
||||
const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } });
|
||||
expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]);
|
||||
const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } });
|
||||
expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']);
|
||||
expect(draft.backends).toHaveLength(1); // untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-006: removeBackend filters by name', () => {
|
||||
expect(removeBackend(draft, 'nas').backends).toEqual([]);
|
||||
expect(removeBackend(draft, 'ghost').backends).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Hoisted to module scope (not just the mirror-fold describe below) so the
|
||||
// usageByBackend suite can reuse the same mirrored fixture the brief's test
|
||||
// snippet assumes is in scope.
|
||||
const MIRRORED_STATE: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{
|
||||
name: 'mirror', type: 'mirror', source: 'settings',
|
||||
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
|
||||
},
|
||||
{ name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] },
|
||||
],
|
||||
categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } },
|
||||
};
|
||||
const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE);
|
||||
|
||||
describe('mirror fold/synthesize (replicas-on-primary)', () => {
|
||||
it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => {
|
||||
const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(degenerate).toEqual([]);
|
||||
expect(rows.map((r) => r.name)).not.toContain('mirror');
|
||||
const primary = rows.find((r) => r.name === 'backups-local')!;
|
||||
expect(primary.mirrorTargets).toEqual(['off-box']);
|
||||
expect(primary.mirrorName).toBe('mirror');
|
||||
expect(primary.categories).toContain('backups'); // via the mirror
|
||||
const replica = rows.find((r) => r.name === 'off-box')!;
|
||||
expect(replica.replicaOf).toEqual(['backups-local']);
|
||||
expect(replica.categories).toContain('covers'); // its own direct assignment survives
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => {
|
||||
const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local
|
||||
const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']);
|
||||
const mirror = next.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.name).toBe('backups-local-mirror');
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] });
|
||||
expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten
|
||||
expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => {
|
||||
const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const mirrors = next.backends.filter((b) => b.type === 'mirror');
|
||||
expect(mirrors).toHaveLength(1);
|
||||
expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed
|
||||
expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => {
|
||||
// Build the synthesized state locally: default backups routed via a fresh mirror…
|
||||
const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']);
|
||||
// …then dissolve it: the backups entry must DROP (state says its default is backups-local).
|
||||
const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories).not.toHaveProperty('backups');
|
||||
// Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping.
|
||||
const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []);
|
||||
expect(repointed.categories.backups).toBe('backups-local');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend,
|
||||
{ name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } },
|
||||
],
|
||||
categories: {},
|
||||
};
|
||||
const { rows, degenerate } = foldBackends(STATE, draft);
|
||||
expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins
|
||||
expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([
|
||||
['m2', 'duplicate-mirror'],
|
||||
['m-env', 'env-primary'],
|
||||
['m-ghost', 'missing-primary'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas' },
|
||||
};
|
||||
const renamed = renameBackendRefs(draft, 'nas', 'nas2');
|
||||
const mirror = renamed.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] });
|
||||
expect(renamed.categories.covers).toBe('nas2');
|
||||
expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => {
|
||||
const draft = mirroredDraft();
|
||||
expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']);
|
||||
const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local');
|
||||
expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false);
|
||||
expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => {
|
||||
// Single-replica mirror: stripping off-box empties it → dissolve + re-point
|
||||
// (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops).
|
||||
const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box');
|
||||
expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories.backups).toBe('backups-local');
|
||||
// Multi-replica mirror: stripping one target keeps the mirror alive with the rest.
|
||||
const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box');
|
||||
const mirror = survived.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] });
|
||||
expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => {
|
||||
expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local');
|
||||
expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback
|
||||
expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replicaCandidates', () => {
|
||||
it('FE-ADMIN-STORM-020: offers only rows that serve no category, never the row being edited', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
// uploads-local/place-photos-local serve categories directly;
|
||||
// backups-local serves backups through the mirror it is the primary of;
|
||||
// off-box carries a direct `covers` assignment. Nothing is offerable.
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: keeps an already-selected target listed so it can be unchecked', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(replicaCandidates(rows, 'backups-local', ['off-box'])).toEqual(['off-box']);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: an unassigned row is offered', () => {
|
||||
const state: StorageAdminState = {
|
||||
...MIRRORED_STATE,
|
||||
backends: [
|
||||
...MIRRORED_STATE.backends,
|
||||
{ name: 'cold-store', type: 'local', source: 'settings', options: { root: '/data/cold' }, categories: [] },
|
||||
],
|
||||
};
|
||||
const { rows } = foldBackends(state, settingsDocumentOf(state));
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual(['cold-store']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usageByBackend', () => {
|
||||
it('FE-ADMIN-STORM-016: sums categories onto their effective backend rows, mirrors onto the primary, legacy onto uploads-local', () => {
|
||||
const usage = {
|
||||
computedAt: 1,
|
||||
categories: Object.fromEntries(
|
||||
STORAGE_CATEGORIES.map((c) => [c, { objects: 1, bytes: 10 }]),
|
||||
) as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 5, bytes: 50 },
|
||||
};
|
||||
const state = { ...MIRRORED_STATE, usage };
|
||||
const sums = usageByBackend(state, mirroredDraft())!;
|
||||
// backups routes via the mirror → attributed to backups-local (the primary row).
|
||||
expect(sums['backups-local']).toEqual({ objects: 1, bytes: 10 });
|
||||
// uploads-local serves 4 direct categories (files, journey, avatars, photos-trek —
|
||||
// this fixture's `places` and `photos-google` default to the env-sourced
|
||||
// place-photos-local backend, not uploads-local) + legacy photos.
|
||||
expect(sums['uploads-local']).toEqual({ objects: 4 + 5, bytes: 40 + 50 });
|
||||
// places + photos-google both default to place-photos-local.
|
||||
expect(sums['place-photos-local']).toEqual({ objects: 2, bytes: 20 });
|
||||
// covers routes to off-box directly.
|
||||
expect(sums['off-box']).toEqual({ objects: 1, bytes: 10 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-017: null usage yields null', () => {
|
||||
expect(usageByBackend({ ...STATE, usage: null }, settingsDocumentOf(STATE))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeMigrationCandidates', () => {
|
||||
it('FE-ADMIN-STORM-018: lists changed categories with usage counts; zero-object ones are excluded; null usage keeps them with null counts', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: {
|
||||
files: { objects: 3, bytes: 300 },
|
||||
journey: { objects: 0, bytes: 0 },
|
||||
} as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', journey: 'off-box', avatars: 'off-box' },
|
||||
};
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: 3, bytes: 300 },
|
||||
{ category: 'avatars', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: null, bytes: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-018b: reassigning onto a mirrored primary yields toWire = the mirror wire name while `to` stays the display primary', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{ name: 'nas', type: 'local', source: 'settings', options: { root: '/mnt/nas' }, categories: [] },
|
||||
{
|
||||
name: 'off-box-mirror',
|
||||
type: 'mirror',
|
||||
source: 'settings',
|
||||
options: { primary: 'off-box', replicas: ['nas'] },
|
||||
categories: [],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: { files: { objects: 3, bytes: 300 } } as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
// Reassign 'files' to the mirrored primary's WIRE name — the same value
|
||||
// setCategory() writes into the draft when the operator picks a mirrored
|
||||
// primary in the UI (adoptedMirrorFor(draft, 'off-box')?.name).
|
||||
const draft: StorageConfig = { ...base, categories: { ...base.categories, files: 'off-box-mirror' } };
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box-mirror', objects: 3, bytes: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-019: stripCategories restores the saved assignment for exactly the named categories', () => {
|
||||
const base = settingsDocumentOf(STATE);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', covers: 'uploads-local' },
|
||||
};
|
||||
|
||||
const strippedFiles = stripCategories(draft, STATE, ['files']);
|
||||
expect(strippedFiles.categories.files).toBeUndefined(); // default-sourced: saved value is "no override"
|
||||
expect(strippedFiles.categories.covers).toBe('uploads-local'); // untouched — not in the strip list
|
||||
|
||||
const strippedCovers = stripCategories(draft, STATE, ['covers']);
|
||||
expect(strippedCovers.categories.covers).toBe('off-box'); // settings-sourced saved value restored explicitly
|
||||
expect(strippedCovers.categories.files).toBe('off-box'); // untouched — not in the strip list
|
||||
});
|
||||
});
|
||||
|
||||
describe('mirrorProbeTargets', () => {
|
||||
it('FE-ADMIN-STORM-020: a non-mirror candidate passes through unchanged', () => {
|
||||
const local: StorageBackend = { name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } };
|
||||
expect(mirrorProbeTargets(settingsDocumentOf(STATE), STATE, local)).toEqual([local]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: expands primary + replicas — draft override wins per name, falling back to state for names the draft never touched', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['off-box'] },
|
||||
};
|
||||
// The draft carries an EDITED off-box (unsaved endpoint change) but never
|
||||
// touches uploads-local (a built-in, never draft-owned).
|
||||
const editedOffBox = {
|
||||
name: 'off-box', type: 's3',
|
||||
options: { ...(STATE.backends[2]!.options as Record<string, unknown>), endpoint: 'http://edited:9000' },
|
||||
} as StorageBackend;
|
||||
const draft: StorageConfig = { backends: [editedOffBox, mirror], categories: {} };
|
||||
|
||||
const targets = mirrorProbeTargets(draft, STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local', 'off-box']);
|
||||
// Primary: no draft entry — resolved from state (the live built-in).
|
||||
expect(targets[0]).toEqual({ name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } });
|
||||
// Replica: draft override wins — the unsaved edit, not the saved options.
|
||||
expect((targets[1]!.options as Record<string, unknown>).endpoint).toBe('http://edited:9000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: a name resolving to neither draft nor state is dropped, not thrown', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['ghost'] },
|
||||
};
|
||||
const targets = mirrorProbeTargets(settingsDocumentOf(STATE), STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageConfigPut,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** One entry of the effective world (GET state), as the panels render it. */
|
||||
export type StateBackend = StorageAdminState['backends'][number]
|
||||
|
||||
/**
|
||||
* The settings-owned document the PUT carries: settings-sourced backends and
|
||||
* settings-sourced category assignments ONLY — built-ins and env backends are
|
||||
* never in the body unless the operator overrides one by name (in which case
|
||||
* the override already reports source 'settings'). Carries `version` from
|
||||
* the state it was built at (audit #7 — optimistic concurrency): the hook
|
||||
* re-attaches this same version on every subsequent local edit, so a PUT
|
||||
* always submits the version the operator's form was loaded at, not
|
||||
* whatever the server currently holds.
|
||||
*/
|
||||
export function settingsDocumentOf(state: StorageAdminState): StorageConfigPut {
|
||||
return {
|
||||
backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(state.categories)
|
||||
.filter(([, entry]) => entry.source === 'settings')
|
||||
.map(([category, entry]) => [category, entry.backend]),
|
||||
) as StorageConfig['categories'],
|
||||
version: state.version,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State options are already schema-shaped (the wire contract guarantees it;
|
||||
* secret fields carry the mask, which the server unmasks by name on PUT/test) —
|
||||
* the cast re-attaches the discriminated-union type the loose state record dropped.
|
||||
*/
|
||||
export function asWireBackend(backend: Pick<StateBackend, 'name' | 'type' | 'options'>): StorageBackend {
|
||||
return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend
|
||||
}
|
||||
|
||||
/** Draft categories pointing at a backend name (the friendly remove pre-check). */
|
||||
export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] {
|
||||
return (Object.entries(draft.categories) as Array<[StorageCategory, string]>)
|
||||
.filter(([, backend]) => backend === name)
|
||||
.map(([category]) => category)
|
||||
}
|
||||
|
||||
/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */
|
||||
export function mirrorsReferencing(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name)))
|
||||
.map((b) => b.name)
|
||||
}
|
||||
|
||||
/** Replace by name in place, or append. Never mutates. */
|
||||
export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig {
|
||||
const exists = draft.backends.some((b) => b.name === backend.name)
|
||||
return {
|
||||
...draft,
|
||||
backends: exists
|
||||
? draft.backends.map((b) => (b.name === backend.name ? backend : b))
|
||||
: [...draft.backends, backend],
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBackend(draft: StorageConfig, name: string): StorageConfig {
|
||||
return { ...draft, backends: draft.backends.filter((b) => b.name !== name) }
|
||||
}
|
||||
|
||||
// ── Mirror fold/synthesize (replicas-on-primary spec) ─────────────────────────
|
||||
// The draft stays the wire document; folding is a view rule, synthesis a set
|
||||
// of pure draft operations. Mirror NAMES never reach the UI — every helper
|
||||
// that renders phrases mirrors as their primary.
|
||||
|
||||
type MirrorBackend = Extract<StorageBackend, { type: 'mirror' }>
|
||||
const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror'
|
||||
|
||||
export interface FoldedBackendRow {
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
source: 'built-in' | 'env' | 'settings'
|
||||
backend: StorageBackend
|
||||
/** Direct assignments ∪ assignments routed via the adopted mirror. */
|
||||
categories: StorageCategory[]
|
||||
/** Adopted mirror's replicas — [] when unmirrored. */
|
||||
mirrorTargets: string[]
|
||||
/** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */
|
||||
mirrorName: string | null
|
||||
/** Primaries whose adopted mirror lists this backend as a replica. */
|
||||
replicaOf: string[]
|
||||
}
|
||||
|
||||
export interface DegenerateMirror {
|
||||
backend: StorageBackend
|
||||
reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary'
|
||||
categories: StorageCategory[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetchable content — replicating it is usually wasteful. photos-google is
|
||||
* the Google photo cache (place-photo-cache.service.ts); places is mostly
|
||||
* provider-derived imagery.
|
||||
*/
|
||||
export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places']
|
||||
|
||||
/** The configurable categories resolved draft-over-state (draft entries win; defaults fill the rest). */
|
||||
export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record<StorageCategory, string> {
|
||||
const map = {} as Record<StorageCategory, string>
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
map[category] = draft.categories[category] ?? state.categories[category]!.backend
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** First draft mirror wrapping the named primary — the one the UI manages. */
|
||||
export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined {
|
||||
return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side mirror expansion for the test probe (audit fix — the server's
|
||||
* `testBackend` resolves a mirror candidate's primary/replicas against its
|
||||
* OWN live snapshot by name, so probing a mirrored row with unsaved draft
|
||||
* edits to those targets would silently test the SAVED options instead of
|
||||
* what's on the form). Draft overrides win per name, falling back to state
|
||||
* (built-in/env); a name that resolves to neither is dropped — that
|
||||
* primary/replica no longer exists, and the row already renders as
|
||||
* degenerate in that case.
|
||||
*/
|
||||
export function mirrorProbeTargets(
|
||||
draft: StorageConfig,
|
||||
state: StorageAdminState,
|
||||
mirror: StorageBackend,
|
||||
): StorageBackend[] {
|
||||
if (mirror.type !== 'mirror') return [mirror]
|
||||
const names = [mirror.options.primary, ...mirror.options.replicas]
|
||||
return names
|
||||
.map((name): StorageBackend | null => {
|
||||
const draftMatch = draft.backends.find((b) => b.name === name)
|
||||
if (draftMatch) return draftMatch
|
||||
const stateMatch = state.backends.find((b) => b.name === name)
|
||||
return stateMatch ? asWireBackend(stateMatch) : null
|
||||
})
|
||||
.filter((b): b is StorageBackend => b !== null)
|
||||
}
|
||||
|
||||
export function foldBackends(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } {
|
||||
const draftNames = draft.backends.map((b) => b.name)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const categoriesOf = (name: string): StorageCategory[] =>
|
||||
STORAGE_CATEGORIES.filter((category) => effective[category] === name)
|
||||
|
||||
// Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state
|
||||
// unless a draft row overrides the name; settings rows from the draft.
|
||||
const base: Array<Pick<FoldedBackendRow, 'name' | 'type' | 'source' | 'backend'>> = []
|
||||
for (const b of state.backends) {
|
||||
if (b.type === 'mirror') continue // mirrors fold below; the draft owns them
|
||||
if (draftNames.includes(b.name)) continue
|
||||
if (b.source === 'settings') continue // removed from the draft, pending save
|
||||
base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) })
|
||||
}
|
||||
for (const b of draft.backends) {
|
||||
if (isMirror(b)) continue
|
||||
base.push({ name: b.name, type: b.type, source: 'settings', backend: b })
|
||||
}
|
||||
|
||||
// Adoption: the first mirror per editable primary; the rest are degenerate —
|
||||
// rendered unfolded with a reason, never silently dropped.
|
||||
const adopted = new Map<string, MirrorBackend>()
|
||||
const degenerate: DegenerateMirror[] = []
|
||||
for (const mirror of draft.backends.filter(isMirror)) {
|
||||
const primary = base.find((row) => row.name === mirror.options.primary)
|
||||
if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) })
|
||||
else adopted.set(primary.name, mirror)
|
||||
}
|
||||
|
||||
const rows = base.map((row): FoldedBackendRow => {
|
||||
const mirror = adopted.get(row.name)
|
||||
return {
|
||||
...row,
|
||||
categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])],
|
||||
mirrorTargets: mirror ? [...mirror.options.replicas] : [],
|
||||
mirrorName: mirror?.name ?? null,
|
||||
replicaOf: [...adopted.values()]
|
||||
.filter((m) => m.options.replicas.includes(row.name))
|
||||
.map((m) => m.options.primary),
|
||||
}
|
||||
})
|
||||
return { rows, degenerate }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows offerable as mirror replicas (the "Mirror targets" picker).
|
||||
*
|
||||
* A backend that already serves a category — directly, or as the primary of a
|
||||
* mirror a category routes to, which is exactly what `row.categories` unions —
|
||||
* can never also be a replica: the mirror's sync sweep makes each replica
|
||||
* MATCH the primary, deleting whatever the primary doesn't hold, and `backups`
|
||||
* sweeps the replica's entire root. The server refuses such a config outright
|
||||
* (storage-registry.service.ts, assertNoSharedReplicas); the picker simply
|
||||
* never offers one, so the operator can't walk into the refusal.
|
||||
*
|
||||
* Targets already selected on this mirror stay listed regardless — they must
|
||||
* remain visible to be unchecked (and a config that predates this rule is
|
||||
* repaired here, not hidden).
|
||||
*/
|
||||
export function replicaCandidates(
|
||||
rows: FoldedBackendRow[],
|
||||
selfName: string | null,
|
||||
currentTargets: readonly string[] = [],
|
||||
): string[] {
|
||||
return rows
|
||||
.filter((row) => row.name !== selfName)
|
||||
.filter((row) => row.categories.length === 0 || currentTargets.includes(row.name))
|
||||
.map((row) => row.name)
|
||||
}
|
||||
|
||||
function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string {
|
||||
const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)])
|
||||
const base = `${primaryName}-mirror`
|
||||
if (!taken.has(base)) return base
|
||||
for (let n = 2; ; n++) {
|
||||
if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* targets non-empty: update the adopted mirror in place (or synthesize one)
|
||||
* and route every category that effectively resolves to the primary —
|
||||
* default-sourced included — through it. targets empty: dissolve, re-pointing
|
||||
* the mirror's categories at the primary and dropping entries that revert to
|
||||
* their built-in default.
|
||||
*/
|
||||
export function setMirrorTargets(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
primaryName: string,
|
||||
targets: string[],
|
||||
): StorageConfig {
|
||||
const adopted = adoptedMirrorFor(draft, primaryName)
|
||||
|
||||
if (targets.length === 0) {
|
||||
if (!adopted) return draft
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] !== adopted.name) continue
|
||||
const stateEntry = state.categories[category]! // exhaustive by schema contract
|
||||
if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category]
|
||||
else categories[category] = primaryName
|
||||
}
|
||||
return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories }
|
||||
}
|
||||
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const mirror: StorageBackend = {
|
||||
name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName),
|
||||
type: 'mirror',
|
||||
options: { primary: primaryName, replicas: [...targets] },
|
||||
}
|
||||
const backends = adopted
|
||||
? draft.backends.map((b) => (b.name === adopted.name ? mirror : b))
|
||||
: [...draft.backends, mirror]
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (effective[category] === primaryName) categories[category] = mirror.name
|
||||
}
|
||||
return { backends, categories }
|
||||
}
|
||||
|
||||
/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */
|
||||
export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig {
|
||||
return {
|
||||
backends: draft.backends.map((b) => {
|
||||
if (!isMirror(b)) return b
|
||||
return {
|
||||
...b,
|
||||
options: {
|
||||
primary: b.options.primary === oldName ? newName : b.options.primary,
|
||||
replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)),
|
||||
},
|
||||
}
|
||||
}),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]),
|
||||
) as StorageConfig['categories'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a backend, every mirror wrapping it as primary, and every replica
|
||||
* reference to it in the remaining mirrors — a mirror stripped of its last
|
||||
* replica dissolves (with the same category re-pointing as an explicit
|
||||
* dissolve), so the draft never carries a phantom reference the composer
|
||||
* cannot render or repair.
|
||||
*/
|
||||
export function removeBackendAndMirrors(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
name: string,
|
||||
): StorageConfig {
|
||||
const stripped = draft.backends
|
||||
.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name))
|
||||
.map((b) =>
|
||||
isMirror(b) && b.options.replicas.includes(name)
|
||||
? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } }
|
||||
: b,
|
||||
)
|
||||
let next: StorageConfig = { ...draft, backends: stripped }
|
||||
for (const mirror of stripped.filter(isMirror)) {
|
||||
if (mirror.options.replicas.length > 0) continue
|
||||
if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) {
|
||||
next = setMirrorTargets(state, next, mirror.options.primary, [])
|
||||
} else {
|
||||
// A degenerate duplicate stripped empty: drop it and re-point its categories.
|
||||
const categories = { ...next.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] === mirror.name) categories[category] = mirror.options.primary
|
||||
}
|
||||
next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories }
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */
|
||||
export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter(isMirror)
|
||||
.filter((m) => m.options.replicas.includes(name))
|
||||
.map((m) => m.options.primary)
|
||||
}
|
||||
|
||||
/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */
|
||||
export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string {
|
||||
const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName)
|
||||
if (draftMirror) return draftMirror.options.primary
|
||||
const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror')
|
||||
if (stateMirror) return String((stateMirror.options as Record<string, unknown>).primary)
|
||||
return backendName
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage sums per VISIBLE backend row: each category's numbers land on the row
|
||||
* that serves it (a mirror-routed category on the mirror's PRIMARY row), and
|
||||
* the legacy photos directory lands on uploads-local — the only backend the
|
||||
* served-legacy `photos` category ever resolves to.
|
||||
*/
|
||||
export function usageByBackend(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): Record<string, { objects: number; bytes: number }> | null {
|
||||
if (!state.usage) return null
|
||||
const sums: Record<string, { objects: number; bytes: number }> = {}
|
||||
const add = (row: string, entry: { objects: number; bytes: number }) => {
|
||||
const current = (sums[row] ??= { objects: 0, bytes: 0 })
|
||||
current.objects += entry.objects
|
||||
current.bytes += entry.bytes
|
||||
}
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const entry = state.usage.categories[category]
|
||||
if (entry) add(primaryNameOf(state, draft, effective[category]), entry)
|
||||
}
|
||||
// The served-legacy `photos` directory always resolves to uploads-local —
|
||||
// hardcoded, not derived, because it is not one of the configurable categories.
|
||||
add(primaryNameOf(state, draft, 'uploads-local'), state.usage.legacyPhotos)
|
||||
return sums
|
||||
}
|
||||
|
||||
// ── Category migration (copy → flip → delta sweep) ────────────────────────────
|
||||
|
||||
/** One reassigned category the operator might want to move existing objects for. */
|
||||
export interface MigrationCandidate {
|
||||
category: StorageCategory
|
||||
from: string
|
||||
/** Display primary — never a hidden mirror name; used by the prompt lines and comparisons. */
|
||||
to: string
|
||||
/**
|
||||
* The RAW effective draft backend name for this category — possibly a
|
||||
* mirror's wire name when `to` is a mirrored primary. The migration POST
|
||||
* must carry this, not `to`: posting the bare primary would silently drop
|
||||
* replication for a category that was just routed onto a mirror.
|
||||
*/
|
||||
toWire: string
|
||||
/** null when usage was never scanned — the prompt still fires, with an unknown-size line. */
|
||||
objects: number | null
|
||||
bytes: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category whose effective (draft-over-state) PRIMARY backend differs
|
||||
* from what's currently saved — normalized through `primaryNameOf` so simply
|
||||
* wrapping (or unwrapping) a mirror around the same primary never counts as
|
||||
* a candidate; mirroring is the existing sync/backfill flow, not a category
|
||||
* migration. Zero-object categories (usage present, 0 objects) are excluded —
|
||||
* nothing to move; null usage (never scanned) keeps the category as a
|
||||
* candidate with null counts, per spec ("unknown still prompts").
|
||||
*/
|
||||
export function computeMigrationCandidates(draft: StorageConfig, state: StorageAdminState): MigrationCandidate[] {
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const candidates: MigrationCandidate[] = []
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const from = primaryNameOf(state, draft, state.categories[category]!.backend) // exhaustive by schema contract
|
||||
const toWire = effective[category]
|
||||
const to = primaryNameOf(state, draft, toWire)
|
||||
if (from === to) continue
|
||||
const usage = state.usage?.categories[category]
|
||||
candidates.push({ category, from, to, toWire, objects: usage ? usage.objects : null, bytes: usage ? usage.bytes : null })
|
||||
}
|
||||
return candidates.filter((c) => c.objects !== 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert exactly the named categories in `draft` back to what's currently
|
||||
* saved in `saved` — same default/settings convention as the rest of this
|
||||
* file (drop the override when the saved entry is default-sourced, write the
|
||||
* explicit backend name otherwise). Every other draft edit is untouched.
|
||||
*/
|
||||
export function stripCategories(
|
||||
draft: StorageConfig,
|
||||
saved: StorageAdminState,
|
||||
categories: StorageCategory[],
|
||||
): StorageConfig {
|
||||
const next = { ...draft.categories }
|
||||
for (const category of categories) {
|
||||
const savedEntry = saved.categories[category]! // exhaustive by schema contract
|
||||
if (savedEntry.source === 'default') delete next[category]
|
||||
else next[category] = savedEntry.backend
|
||||
}
|
||||
return { ...draft, categories: next }
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { getApiErrorMessage, type ApiError } from '../../../types'
|
||||
import { settingsDocumentOf } from './storageModel'
|
||||
|
||||
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
|
||||
|
||||
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
|
||||
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
|
||||
|
||||
/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */
|
||||
function isConflictError(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409
|
||||
}
|
||||
|
||||
export interface StorageAdmin {
|
||||
state: StorageAdminState | null
|
||||
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
|
||||
draft: StorageConfigPut | null
|
||||
dirty: boolean
|
||||
loading: boolean
|
||||
loadError: string | null
|
||||
/** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */
|
||||
saveError: string | null
|
||||
/** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */
|
||||
saveConflict: boolean
|
||||
saving: boolean
|
||||
testResults: StorageTestResults
|
||||
setDraft: (next: StorageConfig) => void
|
||||
/** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */
|
||||
save: (overrideDraft?: StorageConfig) => Promise<boolean>
|
||||
test: (backend: StorageBackend) => Promise<void>
|
||||
/**
|
||||
* Probe each target individually (client-expanded mirror primary +
|
||||
* replicas — see `mirrorProbeTargets`) and merge the results into one
|
||||
* `StorageTestResponse` under `key` (the row's mirror wire name). Never
|
||||
* sends the mirror stub itself — each call probes a concrete backend.
|
||||
*/
|
||||
testMirror: (key: string, targets: StorageBackend[]) => Promise<void>
|
||||
/** null on success; the verbatim server error otherwise. */
|
||||
startBackfill: (mirrorName: string) => Promise<string | null>
|
||||
cancelBackfill: (mirrorName: string) => Promise<string | null>
|
||||
startMigration: (category: string, to: string) => Promise<string | null>
|
||||
cancelMigration: (category: string) => Promise<string | null>
|
||||
refreshStats: () => Promise<string | null>
|
||||
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
|
||||
refreshState: () => Promise<void>
|
||||
/**
|
||||
* The 409's only exit: throw the local edits away and reload the saved
|
||||
* settings (fresh GET → fresh draft → fresh version), clearing dirty and
|
||||
* saveError. See the implementation comment for why refreshState can't do
|
||||
* this itself.
|
||||
*/
|
||||
discardDraft: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the desktop and phone storage panels (DefaultUserSettingsTab
|
||||
* model: self-contained, adminApi directly, deliberately no offline core —
|
||||
* hoster-level config is online-only).
|
||||
*/
|
||||
export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin {
|
||||
const [state, setState] = useState<StorageAdminState | null>(null)
|
||||
const [draft, setDraftState] = useState<StorageConfigPut | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveConflict, setSaveConflict] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testResults, setTestResults] = useState<StorageTestResults>({})
|
||||
|
||||
// Read imperatively from the poll effect/refreshState so a stale closure
|
||||
// never re-derives the draft out from under an in-flight edit.
|
||||
const dirtyRef = useRef(dirty)
|
||||
useEffect(() => {
|
||||
dirtyRef.current = dirty
|
||||
}, [dirty])
|
||||
|
||||
// Read imperatively at refreshState's resolve time so a poll GET that was
|
||||
// in flight when a save started never applies after the save's own state.
|
||||
const savingRef = useRef(saving)
|
||||
useEffect(() => {
|
||||
savingRef.current = saving
|
||||
}, [saving])
|
||||
|
||||
// Bumped by every deliberate state application (initial load, save
|
||||
// success). A refreshState() call captures the seq before its GET and
|
||||
// drops the response if the seq moved on — i.e. a save applied its own
|
||||
// fresher state — while that GET was in flight.
|
||||
const stateSeq = useRef(0)
|
||||
|
||||
const applyState = useCallback((next: StorageAdminState) => {
|
||||
stateSeq.current += 1
|
||||
setState(next)
|
||||
setDraftState(settingsDocumentOf(next))
|
||||
setDirty(false)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.getStorage()
|
||||
.then((data) => {
|
||||
if (!cancelled) applyState(data)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setLoadError(getApiErrorMessage(err, genericError))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
// Re-attaches the CURRENT draft's version onto whatever shape the caller
|
||||
// hands in (the storageModel edit helpers are version-blind — they operate
|
||||
// on plain StorageConfig — so this is the one place that keeps `version`
|
||||
// pinned to what the operator's form was loaded at, no matter how many
|
||||
// local edits run in between).
|
||||
const setDraft = useCallback((next: StorageConfig) => {
|
||||
setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 }))
|
||||
setDirty(true)
|
||||
}, [])
|
||||
|
||||
// Poll-safe refresh: replaces `state` unconditionally, but only re-derives
|
||||
// `draft` when the operator has no unsaved edits in flight — never touches
|
||||
// `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or
|
||||
// mask a pending save error.
|
||||
//
|
||||
// Race guard: a poll GET issued before a save's PUT resolves can resolve
|
||||
// AFTER the save applied its own (fresher) response, overwriting it with
|
||||
// the pre-save world. Capture the state-application sequence number before
|
||||
// the await and drop the response if it moved on, or if a save is
|
||||
// in-flight/just landed (read via a ref so it's current at resolve time).
|
||||
//
|
||||
// Declared before `save` (which calls it on a 409) — a hook's `const`s
|
||||
// must exist before another callback's dependency array references them.
|
||||
const refreshState = useCallback(async (): Promise<void> => {
|
||||
const seq = stateSeq.current
|
||||
const next = await adminApi.getStorage()
|
||||
if (stateSeq.current !== seq || savingRef.current) return
|
||||
setState(next)
|
||||
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
|
||||
}, [])
|
||||
|
||||
const save = useCallback(
|
||||
async (overrideDraft?: StorageConfig): Promise<boolean> => {
|
||||
if (!draft) return false
|
||||
// Always the DRAFT's own version, even for an overrideDraft (a variant
|
||||
// of the same draft with categories stripped, per stripCategories) —
|
||||
// both share the same version basis: what the operator's form was
|
||||
// loaded/refreshed at, never re-read from `state` here (that would
|
||||
// defeat the whole check — see setDraft's re-attach comment above).
|
||||
const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version }
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
try {
|
||||
// The response is the fresh effective world, never the request echo.
|
||||
applyState(await adminApi.updateStorage(body))
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
if (isConflictError(err)) {
|
||||
// Something else (another admin's save, or a category migration's
|
||||
// flip) wrote since this draft's version was captured. Refresh
|
||||
// `state` so the operator sees the fresh world, but the dirty
|
||||
// draft itself is left untouched for review (dirty is never
|
||||
// cleared here) — refreshState() already respects dirtyRef.
|
||||
setSaveError(conflictError)
|
||||
setSaveConflict(true)
|
||||
void refreshState().catch(() => {})
|
||||
} else {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[draft, applyState, genericError, conflictError, refreshState],
|
||||
)
|
||||
|
||||
/**
|
||||
* Conflict recovery (final-review fix): after a 409 the dirty draft keeps
|
||||
* the version it was built at. refreshState deliberately leaves a dirty
|
||||
* draft's version alone — bumping it there would resurrect audit #7, where
|
||||
* the background poll floats a mid-edit draft past a migration flip it
|
||||
* never carried — so nothing else can ever move it, and every retry 409s
|
||||
* again. The operator's way out is to say so explicitly: discard the local
|
||||
* edits, reload the saved settings, and start over from the fresh version.
|
||||
* A fresh GET (not the possibly-dropped post-409 refresh) guarantees that
|
||||
* version is current, and applyState clears dirty/saveError with it.
|
||||
*/
|
||||
const discardDraft = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
applyState(await adminApi.getStorage())
|
||||
} catch (err: unknown) {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
setSaveConflict(false)
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
const test = useCallback(
|
||||
async (backend: StorageBackend): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: 'running' }))
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(backend)
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: result }))
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, genericError)
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] },
|
||||
}))
|
||||
}
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
const testMirror = useCallback(
|
||||
async (key: string, targets: StorageBackend[]): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [key]: 'running' }))
|
||||
const settled = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(target)
|
||||
return result.targets[0] ?? { name: target.name, ok: result.ok }
|
||||
} catch (err: unknown) {
|
||||
return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } }))
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
// While any backfill or category migration is running, poll GET state so
|
||||
// progress/counts advance without the operator refreshing the page.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!state?.backfills.some((b) => b.status === 'running') &&
|
||||
!state?.migrations.some((m) => m.status === 'running')
|
||||
)
|
||||
return
|
||||
const iv = setInterval(() => {
|
||||
// A transient poll failure is retried on the next tick — it must never
|
||||
// surface as a toast or interrupt the operator's in-progress edit.
|
||||
void refreshState().catch(() => {})
|
||||
}, BACKFILL_POLL_MS)
|
||||
return () => clearInterval(iv)
|
||||
}, [state, refreshState])
|
||||
|
||||
const startBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const startMigration = useCallback(
|
||||
async (category: string, to: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageMigration(category, to)
|
||||
// Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the
|
||||
// queue effect's in-flight lock releases as soon as this promise
|
||||
// resolves, so admin.state must already reflect the just-started
|
||||
// migration by then — otherwise the next queued candidate can
|
||||
// dequeue and POST before the server has confirmed this one.
|
||||
await refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelMigration = useCallback(
|
||||
async (category: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageMigration(category)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const refreshStats = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const usage = await adminApi.refreshStorageStats()
|
||||
setState((prev) => (prev ? { ...prev, usage } : prev))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
}, [genericError])
|
||||
|
||||
return {
|
||||
state,
|
||||
draft,
|
||||
dirty,
|
||||
loading,
|
||||
loadError,
|
||||
saveError,
|
||||
saveConflict,
|
||||
saving,
|
||||
testResults,
|
||||
setDraft,
|
||||
save,
|
||||
test,
|
||||
testMirror,
|
||||
startBackfill,
|
||||
cancelBackfill,
|
||||
startMigration,
|
||||
cancelMigration,
|
||||
refreshStats,
|
||||
refreshState,
|
||||
discardDraft,
|
||||
}
|
||||
}
|
||||
@@ -133,4 +133,21 @@ describe('BackgroundTasksWidget', () => {
|
||||
expect(reservationsApi.importBookingAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Warnings used to appear only when nothing at all was found, so a partly
|
||||
* understood import dropped whatever it could not place without a word — a
|
||||
* station the geocoder could not locate, say (#1969), whose endpoint then
|
||||
* disappeared on save.
|
||||
*/
|
||||
it('shows the warnings on a job that did produce items', () => {
|
||||
const warning = 'voucher.pdf: could not locate "Curbside Pickup Counter 7" — set it manually before saving'
|
||||
useBackgroundTasksStore.setState({
|
||||
tasks: [task({ items: [{ id: 1 } as never], warnings: [warning] })],
|
||||
})
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(screen.getByText(new RegExp('Curbside Pickup Counter 7'))).toBeInTheDocument()
|
||||
// and the import button is still there — the job did find something
|
||||
expect(screen.getByRole('button', { name: /import/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -157,13 +157,25 @@ export default function BackgroundTasksWidget() {
|
||||
// Restored from a reload; items are being re-fetched (see the poll backstop).
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
|
||||
) : task.items.length > 0 ? (
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
{/* A partly-understood import warns too. Warnings used to be
|
||||
shown only when nothing at all was found, so anything the
|
||||
parse could not place — a station it could not locate, say
|
||||
(#1969) — was dropped without a word on exactly the imports
|
||||
that did produce something. */}
|
||||
{(task.warnings?.length ?? 0) > 0 && (
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
|
||||
{task.warnings!.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -1020,7 +1020,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { COST_CATEGORIES, typeToCostCategory } from '@trek/shared'
|
||||
import { COST_CAT_META, COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
|
||||
describe('COST_CAT_META', () => {
|
||||
it('has metadata for every fixed category, including fuel and parking', () => {
|
||||
for (const key of COST_CATEGORIES) {
|
||||
expect(COST_CAT_META[key]).toBeDefined()
|
||||
expect(COST_CAT_META[key].key).toBe(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('derives COST_CATEGORY_LIST in the same order as COST_CATEGORIES', () => {
|
||||
expect(COST_CATEGORY_LIST.map(m => m.key)).toEqual([...COST_CATEGORIES])
|
||||
})
|
||||
})
|
||||
|
||||
describe('catMeta', () => {
|
||||
it('resolves fuel and parking directly', () => {
|
||||
expect(catMeta('fuel').key).toBe('fuel')
|
||||
expect(catMeta('parking').key).toBe('parking')
|
||||
})
|
||||
|
||||
it('maps legacy/free-text aliases to the new categories', () => {
|
||||
expect(catMeta('gas').key).toBe('fuel')
|
||||
expect(catMeta('petrol').key).toBe('fuel')
|
||||
expect(catMeta('Parking').key).toBe('parking')
|
||||
expect(catMeta('car park').key).toBe('parking')
|
||||
})
|
||||
|
||||
it('resolves a parking booking the same way whichever path created the expense', () => {
|
||||
expect(catMeta(typeToCostCategory('parking')).key).toBe('parking')
|
||||
expect(catMeta('parking').key).toBe(typeToCostCategory('parking'))
|
||||
})
|
||||
|
||||
it('falls back to other for unknown categories', () => {
|
||||
expect(catMeta('made-up-category').key).toBe('other')
|
||||
expect(catMeta(null).key).toBe('other')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
|
||||
@@ -27,6 +27,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
|
||||
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
|
||||
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
|
||||
fuel: { key: 'fuel', labelKey: 'costs.cat.fuel', Icon: Fuel, color: '#f59e0b' },
|
||||
parking: { key: 'parking', labelKey: 'costs.cat.parking', Icon: ParkingCircle, color: '#3b82f6' },
|
||||
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
|
||||
}
|
||||
|
||||
@@ -51,6 +53,8 @@ const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
|
||||
parking: 'parking', parkings: 'parking', 'car park': 'parking',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ export function ChatMessages(props: any) {
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} />
|
||||
// flex-1, like the message list it stands in for: without it the empty
|
||||
// state was only as tall as its own content and the composer rode up to
|
||||
// sit right under the mascot, with the rest of the panel left blank.
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} className="min-h-0 flex-1" />
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
|
||||
@@ -539,14 +539,14 @@ describe('EntryEditor', () => {
|
||||
expect(onUploadPhotos).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-026: leaves a skeleton without any content a skeleton', async () => {
|
||||
it('FE-JRN-EDITOR-026: promotes a skeleton the user saved without editing (#2008)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 21, type: 'skeleton', title: 'Venice' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ type: undefined })
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' })
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-027: removes a con row again', async () => {
|
||||
@@ -836,4 +836,46 @@ describe('EntryEditor', () => {
|
||||
expect(amazing.className).toContain('border-zinc-200')
|
||||
expect(amazing.getAttribute('style')).toBe('')
|
||||
})
|
||||
|
||||
// #1614 — entry_time was stored, sent and displayed everywhere, but the desktop
|
||||
// editor never rendered an input for it.
|
||||
it('FE-JRN-EDITOR-041: shows the stored time and saves an edited one', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10, title: 'Old', entry_time: '14:30:00' }))
|
||||
|
||||
// The column carries HH:MM:SS; a time input only accepts HH:MM.
|
||||
const timeInput = screen.getByDisplayValue('14:30')
|
||||
expect(timeInput).toHaveAttribute('type', 'time')
|
||||
|
||||
fireEvent.change(timeInput, { target: { value: '09:05' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: '09:05' }))
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-042: clearing the time sends null rather than an empty string', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10, entry_time: '14:30:00' }))
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('14:30'), { target: { value: '' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
|
||||
const { container } = mountEditor(buildEntry({ id: 10 }))
|
||||
|
||||
const inputs = Array.from(container.querySelectorAll('input[type="file"]'))
|
||||
const camera = inputs.filter(i => i.getAttribute('capture') === 'environment')
|
||||
const library = inputs.filter(i => !i.hasAttribute('capture'))
|
||||
|
||||
expect(camera).toHaveLength(1)
|
||||
expect(library).toHaveLength(1)
|
||||
// The library picker keeps multi-select; forcing capture onto it would drop that.
|
||||
expect(library[0]).toHaveAttribute('multiple')
|
||||
expect(camera[0]).not.toHaveAttribute('multiple')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate } from 'lucide-react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -34,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
||||
const [entryTime, setEntryTime] = useState(entry.entry_time || '')
|
||||
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
|
||||
const [locationName, setLocationName] = useState(entry.location_name || '')
|
||||
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
|
||||
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
|
||||
@@ -60,6 +60,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const [externalProvider, setExternalProvider] = useState<string | null>(null)
|
||||
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
// Own input: putting `capture` on the picker above would take the photo library
|
||||
// away on a phone, which is the more common way in. This one only ever opens the
|
||||
// camera, so tablets and laptops get the same route the phone sheet already has.
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
|
||||
|
||||
@@ -71,7 +75,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
title !== (entry.title || '') ||
|
||||
story !== (entry.story || '') ||
|
||||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||
entryTime !== (entry.entry_time || '') ||
|
||||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
|
||||
locationName !== (entry.location_name || '') ||
|
||||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||
@@ -140,7 +144,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
mood: mood || null,
|
||||
weather: weather || null,
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0 || pendingProviderGroups.length > 0)) ? 'entry' : undefined),
|
||||
// An explicit Save is the user saying this suggestion is now their entry —
|
||||
// it does not need a story to earn that (#2008).
|
||||
type: entry.type === 'skeleton' ? 'entry' : undefined,
|
||||
}, persistedEntryIdRef.current ?? undefined)
|
||||
if (entryId > 0) persistedEntryIdRef.current = entryId
|
||||
// upload queued files after entry is created
|
||||
@@ -258,6 +264,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
|
||||
<div>
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); fileRef.current?.click() }}
|
||||
@@ -282,6 +289,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<Image size={13} /> {t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
|
||||
disabled={saving}
|
||||
aria-label={t('journey.photo.add')}
|
||||
title={t('journey.photo.add')}
|
||||
className="border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
@@ -559,9 +575,23 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
||||
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
|
||||
{/* Time sat in state and went to the server, it just had no input here — so a
|
||||
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
|
||||
and the public share, and the desktop had no way to correct it (#1614). */}
|
||||
<div className="grid grid-cols-[1fr_104px] gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
||||
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('mobileJourney.time')}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={entryTime}
|
||||
onChange={e => setEntryTime(e.target.value)}
|
||||
className="w-full h-[42px] px-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-transparent text-[13px] text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500 [font-variant-numeric:tabular-nums]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.location')}</label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act } from 'react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -22,6 +23,31 @@ const uploadGalleryPhotos = vi.fn(
|
||||
|
||||
const JOURNEY_ID = 9
|
||||
|
||||
// The global IntersectionObserver stub never fires; this one hands the callback
|
||||
// back so the gallery's load-more sentinel can be driven into view on demand.
|
||||
const galleryObservers: { cb: IntersectionObserverCallback; el: Element | null }[] = []
|
||||
|
||||
function triggerIntersection() {
|
||||
for (const o of galleryObservers) {
|
||||
o.cb([{ isIntersecting: true, target: o.el } as unknown as IntersectionObserverEntry], null as never)
|
||||
}
|
||||
}
|
||||
|
||||
function stubIntersectionObserver() {
|
||||
galleryObservers.length = 0
|
||||
vi.stubGlobal('IntersectionObserver', class {
|
||||
observe: (el: Element) => void
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
takeRecords = vi.fn(() => [])
|
||||
constructor(cb: IntersectionObserverCallback) {
|
||||
const entry: { cb: IntersectionObserverCallback; el: Element | null } = { cb, el: null }
|
||||
galleryObservers.push(entry)
|
||||
this.observe = (el: Element) => { entry.el = el }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildGalleryPhoto(overrides: Partial<GalleryPhoto> = {}): GalleryPhoto {
|
||||
return {
|
||||
id: 100,
|
||||
@@ -303,4 +329,27 @@ describe('GalleryView', () => {
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Some photos failed to upload', 'error', undefined))
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
// #1614 — a long trip's gallery is hundreds of tiles; rendering them all at once
|
||||
// is what made scrolling it a chore.
|
||||
it('FE-JRN-GALLERY-017: renders a first page and grows as the trigger comes into view', () => {
|
||||
stubIntersectionObserver()
|
||||
const many = Array.from({ length: 145 }, (_, i) => buildGalleryPhoto({ id: 100 + i, photo_id: 100 + i }))
|
||||
const { container } = mountGallery(many)
|
||||
|
||||
const tiles = () => container.querySelectorAll('.aspect-square')
|
||||
expect(tiles()).toHaveLength(60)
|
||||
|
||||
// The observer is stubbed in this suite, so drive the trigger the way it would.
|
||||
act(() => { triggerIntersection() })
|
||||
expect(tiles()).toHaveLength(120)
|
||||
|
||||
act(() => { triggerIntersection() })
|
||||
expect(tiles()).toHaveLength(145)
|
||||
})
|
||||
|
||||
it('FE-JRN-GALLERY-018: a gallery below one page shows no trigger at all', () => {
|
||||
const { container } = mountGallery([buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })])
|
||||
expect(container.querySelectorAll('.aspect-square')).toHaveLength(2)
|
||||
expect(container.querySelector('.animate-spin')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
|
||||
@@ -54,6 +55,14 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
}, [])
|
||||
|
||||
const allPhotos = gallery
|
||||
// A long trip's gallery is hundreds of tiles, and rendering them all at once is
|
||||
// what makes scrolling it a chore (#1614). The provider picker has grown its own
|
||||
// way in for the same reason; this reuses it rather than inventing a second one.
|
||||
const GALLERY_PAGE = 60
|
||||
const [visibleCount, setVisibleCount] = useState(GALLERY_PAGE)
|
||||
const shownPhotos = allPhotos.slice(0, visibleCount)
|
||||
// Deleting or adding photos must not leave the window stranded past the end.
|
||||
useEffect(() => { setVisibleCount(c => Math.min(Math.max(c, GALLERY_PAGE), Math.max(allPhotos.length, GALLERY_PAGE))) }, [allPhotos.length])
|
||||
|
||||
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
||||
|
||||
@@ -143,7 +152,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
<EmptyState scene="journey" title={t('journey.detail.noPhotos')} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||
{allPhotos.map((photo, i) => (
|
||||
{shownPhotos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
@@ -193,6 +202,9 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{visibleCount < allPhotos.length && (
|
||||
<ScrollTrigger onVisible={() => setVisibleCount(c => c + GALLERY_PAGE)} loading={false} />
|
||||
)}
|
||||
|
||||
{/* Provider Photo Picker Modal */}
|
||||
{showPicker && (
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
{/* Header */}
|
||||
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
|
||||
@@ -29,6 +29,10 @@ vi.mock('leaflet', () => {
|
||||
getZoom: vi.fn(() => 10),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
};
|
||||
return {
|
||||
default: {
|
||||
@@ -38,6 +42,7 @@ vi.mock('leaflet', () => {
|
||||
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
layerGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), remove: vi.fn() })),
|
||||
},
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
@@ -550,4 +555,61 @@ describe('JourneyMap', () => {
|
||||
expect(colors).not.toContain('#ff0000');
|
||||
expect(colors).not.toContain('#ffffff');
|
||||
});
|
||||
|
||||
// A string handed to bindTooltip becomes innerHTML. A track name is a place
|
||||
// name off a shared trip and an entry label is a journey title, so both are
|
||||
// written by someone who is not necessarily the person reading the map — and
|
||||
// this component is what the public journey page renders.
|
||||
it('FE-COMP-JOURNEYMAP-044: escapes a track name before it becomes tooltip markup', () => {
|
||||
vi.mocked(L.polyline).mockClear();
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ name: '<img src=x onerror="alert(1)">' })]} />);
|
||||
|
||||
const line = vi.mocked(L.polyline).mock.results
|
||||
.map(r => r.value as any)
|
||||
.find(v => v.bindTooltip.mock.calls.length > 0);
|
||||
const [label] = line.bindTooltip.mock.calls[0];
|
||||
expect(label).not.toContain('<img');
|
||||
expect(label).not.toContain('onerror="');
|
||||
expect(label).toBe('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-045: escapes an entry title before it becomes tooltip markup', () => {
|
||||
const hostile = [{ id: 'e9', lat: 48.1, lng: 2.1, title: '<img src=x onerror="alert(1)">', mood: null, entry_date: '2025-06-04' }];
|
||||
render(<JourneyMap checkins={[]} entries={hostile} />);
|
||||
|
||||
const labels = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
|
||||
expect(labels.some(l => typeof l === 'string' && l.includes('<img'))).toBe(false);
|
||||
expect(labels).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
// #1614 — photos placed by their own capture coordinates, collapsed by proximity.
|
||||
it('FE-COMP-JOURNEYMAP-046: draws one thumbnail per cluster and counts the rest', () => {
|
||||
render(
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={[]}
|
||||
photos={[
|
||||
{ id: 'p1', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/a.jpg' },
|
||||
{ id: 'p2', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/b.jpg' },
|
||||
{ id: 'p3', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/c.jpg' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The mock projects every photo to the same point, so all three land in one
|
||||
// bucket: one marker, and its badge says 3.
|
||||
const html = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
|
||||
expect(html).toHaveLength(1);
|
||||
expect(html[0]).toContain('/uploads/a.jpg');
|
||||
expect(html[0]).toContain('>3<');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-047: a lone photo gets no count badge', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/only.jpg' }]} />,
|
||||
);
|
||||
|
||||
const html = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
|
||||
expect(html).toHaveLength(1);
|
||||
expect(html[0]).not.toMatch(/>\d+</);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
id: string
|
||||
@@ -14,12 +14,59 @@ export interface MapMarkerItem {
|
||||
dayLabel: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid clustering in screen space.
|
||||
*
|
||||
* The Journey maps have never had clustering, and the library the planner uses
|
||||
* hangs off react-leaflet while this map drives Leaflet directly. Bucketing by
|
||||
* rounded pixel position is a few lines, is deterministic, and is enough for the
|
||||
* job: photos of one place collapse into one thumbnail with a count, and pulling
|
||||
* the map apart separates them again.
|
||||
*/
|
||||
const PHOTO_CLUSTER_PX = 64
|
||||
|
||||
function clusterPhotos(
|
||||
map: L.Map,
|
||||
photos: MapPhoto[],
|
||||
): { lat: number; lng: number; members: MapPhoto[] }[] {
|
||||
const buckets = new Map<string, MapPhoto[]>()
|
||||
for (const photo of photos) {
|
||||
const pt = map.latLngToContainerPoint([photo.lat, photo.lng])
|
||||
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
|
||||
const list = buckets.get(key)
|
||||
if (list) list.push(photo)
|
||||
else buckets.set(key, [photo])
|
||||
}
|
||||
return [...buckets.values()].map(members => ({
|
||||
// Anchor on the first member rather than the centroid: the thumbnail shown is
|
||||
// that photo's, so the pin should point where that picture was taken.
|
||||
lat: members[0].lat,
|
||||
lng: members[0].lng,
|
||||
members,
|
||||
}))
|
||||
}
|
||||
|
||||
function photoMarkerHtml(thumbUrl: string, count: number): string {
|
||||
const badge = count > 1
|
||||
? `<span style="position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:#fff;border:1.5px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;">${count}</span>`
|
||||
: ''
|
||||
return `<div style="position:relative;width:48px;height:48px;border-radius:12px;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);background-image:url('${encodeURI(thumbUrl)}');background-size:cover;background-position:center;"></div>${badge}`
|
||||
}
|
||||
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
/** A photo that knows where it was taken (#1614). */
|
||||
export interface MapPhoto {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
thumbUrl: string
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
@@ -35,6 +82,9 @@ interface Props {
|
||||
ref?: Ref<JourneyMapHandle>
|
||||
checkins: any[]
|
||||
entries: MapEntry[]
|
||||
/** Photos placed by their own capture coordinates, clustered by proximity. */
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
trail?: { lat: number; lng: number }[]
|
||||
/** Routed GPX geometries from the journey's trips (#1260). */
|
||||
tracks?: JourneyTrack[]
|
||||
@@ -92,7 +142,7 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
@@ -104,6 +154,9 @@ function JourneyMap(
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
const photoLayerRef = useRef<L.LayerGroup | null>(null)
|
||||
const onPhotoClickRef = useRef(onPhotoClick)
|
||||
onPhotoClickRef.current = onPhotoClick
|
||||
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
@@ -212,8 +265,9 @@ function JourneyMap(
|
||||
const line = L.polyline(coords, { color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' })
|
||||
// Same tooltip the markers on this map use, rather than Leaflet's default box:
|
||||
// it follows the appearance tokens, so it lands right in dark mode and with
|
||||
// transparency switched off.
|
||||
if (track.name) line.bindTooltip(track.name, { sticky: true, direction: 'top', className: 'map-tooltip' })
|
||||
// transparency switched off. Escaped because a string handed to bindTooltip
|
||||
// becomes innerHTML, and a track name is a place name off a shared trip.
|
||||
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
|
||||
line.addTo(map)
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
}
|
||||
@@ -243,7 +297,9 @@ function JourneyMap(
|
||||
})
|
||||
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
marker.bindTooltip(item.label, {
|
||||
// Escaped for the same reason as the track tooltip above: the label is an
|
||||
// entry title, and this map is what the public journey page renders.
|
||||
marker.bindTooltip(escapeHtml(item.label), {
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip',
|
||||
@@ -281,6 +337,49 @@ function JourneyMap(
|
||||
}
|
||||
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
|
||||
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
|
||||
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
|
||||
// pan because the clustering is done in screen space.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
|
||||
const draw = () => {
|
||||
photoLayerRef.current?.remove()
|
||||
photoLayerRef.current = null
|
||||
if (!photos?.length) return
|
||||
|
||||
const group = L.layerGroup()
|
||||
for (const cluster of clusterPhotos(map, photos)) {
|
||||
const marker = L.marker([cluster.lat, cluster.lng], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24],
|
||||
html: photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length),
|
||||
}),
|
||||
// Below the entry pins: the itinerary is the point of the map, the photos
|
||||
// are context.
|
||||
zIndexOffset: -500,
|
||||
})
|
||||
marker.on('click', () => onPhotoClickRef.current?.(cluster.members.map(m => m.id)))
|
||||
group.addLayer(marker)
|
||||
}
|
||||
group.addTo(map)
|
||||
photoLayerRef.current = group
|
||||
}
|
||||
|
||||
draw()
|
||||
map.on('zoomend', draw)
|
||||
map.on('moveend', draw)
|
||||
return () => {
|
||||
map.off('zoomend', draw)
|
||||
map.off('moveend', draw)
|
||||
photoLayerRef.current?.remove()
|
||||
photoLayerRef.current = null
|
||||
}
|
||||
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
if (!activeMarkerId || !mapRef.current) return
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { visibleManagedNavItems } from '../../managed'
|
||||
|
||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||
@@ -59,6 +61,7 @@ export default function BottomNav() {
|
||||
const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page')
|
||||
const location = useLocation()
|
||||
const create = useCreateAction()
|
||||
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
|
||||
|
||||
const items: NavItem[] = [
|
||||
{ to: '/dashboard', label: t('nav.myTrips'), icon: LayoutGrid },
|
||||
@@ -67,6 +70,8 @@ export default function BottomNav() {
|
||||
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
||||
}),
|
||||
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })),
|
||||
// Empty in this repository — see client/src/managed.
|
||||
...visibleManagedNavItems(isAdmin).map(m => ({ to: m.path, label: m.label, icon: m.Icon })),
|
||||
]
|
||||
// Split the items so the raised "+" sits dead centre.
|
||||
const splitAt = Math.ceil(items.length / 2)
|
||||
|
||||
@@ -559,3 +559,63 @@ describe('Navbar styling and menu details', () => {
|
||||
document.documentElement.classList.remove('trek-theme-transitioning');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The bar's three columns (#1983).
|
||||
*
|
||||
* The tab pill was absolutely positioned on the centre of the bar, so it had no
|
||||
* relationship to what sat beside it. Its width grows with every enabled addon
|
||||
* and every page plugin, and once it outgrew the free space in the middle it
|
||||
* ran underneath the logo on one side and the user menu on the other. The only
|
||||
* adaptation was a fixed 1024px breakpoint that drops the labels, tuned when
|
||||
* two or three addons was the whole story and blind to plugins entirely.
|
||||
*
|
||||
* These check the shape rather than pixels, because that is where the defect
|
||||
* was: three columns in the flow cannot overlap, whatever ends up in them, and
|
||||
* no measurement has to be right for that to hold. jsdom reports every width as
|
||||
* zero, so an assertion on how wide anything is would pass for the wrong reason.
|
||||
*/
|
||||
describe('Navbar layout (#1983)', () => {
|
||||
const withAddons = (n: number) => {
|
||||
const addons = Array.from({ length: n }, (_, i) => ({
|
||||
id: `addon${i}`, name: `Addon ${i}`, icon: 'CalendarDays', type: 'global' as const, enabled: true,
|
||||
}));
|
||||
server.use(http.get('/api/addons', () => HttpResponse.json({ addons })));
|
||||
seedStore(useAddonStore, { addons });
|
||||
};
|
||||
|
||||
it('keeps the tab pill in the flow rather than floating over its neighbours', () => {
|
||||
withAddons(4);
|
||||
const { container } = render(<Navbar />);
|
||||
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
|
||||
expect(pill).toBeTruthy();
|
||||
// The assertion that would have caught the overlap.
|
||||
expect(pill.style.position).not.toBe('absolute');
|
||||
});
|
||||
|
||||
it('gives the columns either side of it equal weight, so it stays centred', () => {
|
||||
withAddons(4);
|
||||
const { container } = render(<Navbar />);
|
||||
const nav = container.querySelector('nav') as HTMLElement;
|
||||
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
|
||||
// Left brand column and right action cluster, both flex-1 basis-0.
|
||||
expect(columns).toHaveLength(2);
|
||||
for (const c of columns) expect(c.classList.contains('basis-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the pill shrink instead of pushing the actions off the bar', () => {
|
||||
withAddons(8);
|
||||
const { container } = render(<Navbar />);
|
||||
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
|
||||
expect(pill.classList.contains('min-w-0')).toBe(true);
|
||||
expect(pill.style.overflowX).toBe('auto');
|
||||
});
|
||||
|
||||
it('still renders every addon as a reachable link, however many there are', () => {
|
||||
withAddons(8);
|
||||
render(<Navbar />);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
expect(screen.getByRole('link', { name: new RegExp(`Addon ${i}`, 'i') })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, S
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import { visibleManagedNavItems } from '../../managed'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
|
||||
|
||||
@@ -109,8 +110,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
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]">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
||||
the tab pill between them sits in the middle of the bar rather than
|
||||
being centred on top of both (#1983). */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 basis-0">
|
||||
{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 text-content-muted"
|
||||
@@ -136,14 +139,27 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so
|
||||
the left brand block and the right action cluster keep their layout. */}
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
|
||||
In the flow, between two equally weighted flex columns, rather than
|
||||
absolutely positioned on the centre of the bar. Out of the flow it had
|
||||
no relationship to its neighbours at all: its width grows with every
|
||||
enabled addon and every page plugin, and once it outgrew the free
|
||||
space in the middle it simply ran underneath the logo on one side and
|
||||
the user menu on the other (#1983). The only adaptation was a fixed
|
||||
1024px breakpoint that drops the labels, which was tuned for two or
|
||||
three addons and cannot know about plugins.
|
||||
|
||||
Now the three columns share the bar, so overlap is not something that
|
||||
can happen: the pill takes the width it needs and the columns beside
|
||||
it give way. min-w-0 lets it shrink past its content and scroll rather
|
||||
than push the actions off the bar. */}
|
||||
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
|
||||
<div
|
||||
className="trek-nav-pill"
|
||||
className="trek-nav-pill min-w-0"
|
||||
style={{
|
||||
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
|
||||
display: 'flex', gap: 4, padding: 4, borderRadius: 14,
|
||||
display: 'flex', gap: 4, padding: 4, borderRadius: 14, flexShrink: 1,
|
||||
overflowX: 'auto', scrollbarWidth: 'none',
|
||||
background: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)', WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'}`,
|
||||
@@ -151,7 +167,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
>
|
||||
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) }))
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
|
||||
// Empty in this repository — see client/src/managed.
|
||||
...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
|
||||
].map(tab => {
|
||||
const isActive = location.pathname === tab.path
|
||||
return (
|
||||
@@ -189,8 +207,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
{/* Right side. Same weight as the left column, so whatever is between them
|
||||
is centred on the bar (#1983). Was a bare flex-1 spacer followed by
|
||||
loose siblings, which centred nothing and left the pill to fend for
|
||||
itself on top of them. */}
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-0 justify-end">
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
@@ -321,6 +342,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -916,7 +916,17 @@ describe('MapView live location', () => {
|
||||
})
|
||||
|
||||
describe('MapView bounds fitting', () => {
|
||||
it('FE-COMP-MAPVIEW-065: a day-detail fit nudges the map down once the animation ran (#1348)', async () => {
|
||||
/*
|
||||
* The day fit used to compensate for the day panel twice: once through the
|
||||
* padding, and again 300ms later with panBy([0, 150]) (#1348). On a route
|
||||
* running north to south the fit is height-bound, so the northernmost stop
|
||||
* lands exactly on the top padding line and the nudge then pushed it off the
|
||||
* canvas — the map framed everything and then drifted upwards (#1982).
|
||||
*
|
||||
* So this case is the inverse of what it used to assert: the padding does the
|
||||
* work, and nothing moves the camera afterwards.
|
||||
*/
|
||||
it('FE-COMP-MAPVIEW-065: a day-detail fit reserves the panel in its padding and then leaves the camera alone (#1348, #1982)', async () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48, lng: 2 }),
|
||||
buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }),
|
||||
@@ -925,7 +935,26 @@ describe('MapView bounds fitting', () => {
|
||||
rerender(<MapView places={places} dayPlaces={places} fitKey={2} hasDayDetail />)
|
||||
|
||||
expect(mapMock.fitBounds).toHaveBeenCalled()
|
||||
await waitFor(() => expect(mapMock.panBy).toHaveBeenCalledWith([0, 150], { animate: true }), { timeout: 2000 })
|
||||
const calls = mapMock.fitBounds.mock.calls
|
||||
const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] }
|
||||
expect(opts?.paddingBottomRight?.[1]).toBe(280)
|
||||
|
||||
// Long enough that the old 300ms nudge would have fired by now.
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
expect(mapMock.panBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-065b: reserves only the small margin when no day panel is open (#1982)', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48, lng: 2 }),
|
||||
buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }),
|
||||
]
|
||||
const { rerender } = render(<MapView places={places} dayPlaces={places} fitKey={1} />)
|
||||
rerender(<MapView places={places} dayPlaces={places} fitKey={2} />)
|
||||
|
||||
const calls = mapMock.fitBounds.mock.calls
|
||||
const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] }
|
||||
expect(opts?.paddingBottomRight?.[1]).toBe(60)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-066: a fitKey that never changed is not re-fitted', async () => {
|
||||
|
||||
@@ -269,7 +269,6 @@ function MapController({ center, zoom }: MapControllerProps) {
|
||||
// finished computing asynchronously — re-fit once more to include the full route
|
||||
// polyline, so a route that bulges past its stops stays in view (#1128).
|
||||
interface BoundsControllerProps {
|
||||
hasDayDetail?: boolean
|
||||
places: Place[]
|
||||
routeCoords: [number, number][]
|
||||
fitKey: number
|
||||
@@ -278,7 +277,7 @@ interface BoundsControllerProps {
|
||||
framedOnMount?: boolean
|
||||
}
|
||||
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDetail, framedOnMount = false }: BoundsControllerProps) {
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const awaitingRoute = useRef(false)
|
||||
@@ -289,13 +288,24 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDeta
|
||||
try {
|
||||
const bounds = L.latLngBounds(coords)
|
||||
if (bounds.isValid()) {
|
||||
/*
|
||||
* The padding already reserves the day panel's height at the bottom
|
||||
* (paddingBox), so the fit puts the day's stops above it. There used to
|
||||
* be a second, manual nudge 300ms later — panBy([0, 150]) — from the
|
||||
* same change that introduced the padding, and the two compensated for
|
||||
* the same panel twice.
|
||||
*
|
||||
* On a route that runs north to south the fit is height-bound, which
|
||||
* puts the northernmost stop exactly on the top padding line; the nudge
|
||||
* then pushed it off the canvas. The map appeared to frame everything
|
||||
* correctly and then drift upwards, which is what the report describes
|
||||
* (#1982). MapViewGL never had the nudge, so this also brings the two
|
||||
* renderers back into agreement.
|
||||
*/
|
||||
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||
if (hasDayDetail) {
|
||||
setTimeout(() => map.panBy([0, 150], { animate: true }), 300)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [map, paddingOpts, hasDayDetail])
|
||||
}, [map, paddingOpts])
|
||||
|
||||
// New fitKey (initial trip fit or a day selection): fit to the destinations now
|
||||
// and arm a one-shot re-fit for when the route arrives.
|
||||
@@ -827,7 +837,7 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} framedOnMount={initialView.framed} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
@@ -899,7 +909,10 @@ export const MapView = memo(function MapView({
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
<div style={{ position: 'absolute', left: leftWidth + 12, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -851,6 +851,23 @@ describe('MapViewGL', () => {
|
||||
expect(glMarkers.created[1].lngLat).toEqual([2.12, 48.12])
|
||||
})
|
||||
|
||||
// The Leaflet twin of this is FE-COMP-MAPVIEW-074. Both renderers build their
|
||||
// marker as an HTML string, so both need the escape pinned — otherwise it can
|
||||
// be dropped from one of them without a single test going red.
|
||||
it('FE-COMP-MAPVIEWGL-023b: an image_url that passes the prefix check is still escaped', async () => {
|
||||
loadOnAttach()
|
||||
render(<MapViewGL places={[buildMapPlace({
|
||||
id: 13, lat: 48.13, lng: 2.13, image_url: '/uploads/x" onerror="alert(1)" y="',
|
||||
})]} fitKey={1} />)
|
||||
await act(async () => {})
|
||||
|
||||
const marker = glMarkers.created[0].element
|
||||
expect(marker.innerHTML).not.toContain('onerror="alert(1)"')
|
||||
expect(marker.innerHTML).toContain('"')
|
||||
// The escape must not break the ordinary case: the src still resolves.
|
||||
expect(marker.querySelector('img')?.getAttribute('src')).toBe('/uploads/x" onerror="alert(1)" y="')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-024: the hover card shows name, category and address and follows the cursor', async () => {
|
||||
loadOnAttach()
|
||||
const places = [buildMapPlace({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
calculateSegments,
|
||||
optimizeRoute,
|
||||
generateGoogleMapsUrl,
|
||||
generateCoMapsUrl,
|
||||
parsePluginProfile,
|
||||
withHotelBookends,
|
||||
} from './RouteCalculator'
|
||||
@@ -572,3 +573,43 @@ describe('calculateRouteWithLegs cache eviction', () => {
|
||||
expect(hits).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── generateCoMapsUrl ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateCoMapsUrl', () => {
|
||||
const eiffel = { lat: 48.8584, lng: 2.2945, name: 'Eiffel Tower' }
|
||||
const louvre = { lat: 48.8606, lng: 2.3376, name: 'Louvre' }
|
||||
const notre = { lat: 48.8530, lng: 2.3499, name: 'Notre-Dame' }
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-016: no stops, no link', () => {
|
||||
expect(generateCoMapsUrl([])).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-017: two stops build a real route, mode included', () => {
|
||||
expect(generateCoMapsUrl([eiffel, louvre], 'walking')).toBe(
|
||||
'https://comaps.at/route?sll=48.8584,2.2945&saddr=Eiffel%20Tower'
|
||||
+ '&dll=48.8606,2.3376&daddr=Louvre&type=pedestrian',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-018: TREK profiles map onto CoMaps travel modes', () => {
|
||||
expect(generateCoMapsUrl([eiffel, louvre], 'driving')).toContain('type=vehicle')
|
||||
expect(generateCoMapsUrl([eiffel, louvre], 'cycling')).toContain('type=bicycle')
|
||||
// A plugin router has no CoMaps equivalent, so it falls back rather than
|
||||
// sending a mode CoMaps would reject.
|
||||
expect(generateCoMapsUrl([eiffel, louvre], 'plugin:ev/fast')).toContain('type=vehicle')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-019: three stops go as pins, because a route link would drop the middle', () => {
|
||||
const url = generateCoMapsUrl([eiffel, louvre, notre])!
|
||||
expect(url).toBe(
|
||||
'https://comaps.at/map?v=1&ll=48.8584,2.2945&n=Eiffel%20Tower'
|
||||
+ '&ll=48.8606,2.3376&n=Louvre&ll=48.853,2.3499&n=Notre-Dame',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-020: a single stop is a pin, and a nameless one is labelled by position', () => {
|
||||
expect(generateCoMapsUrl([{ lat: 48.85, lng: 2.35 }]))
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,6 +116,42 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||
return `https://www.google.com/maps/dir/${stops}`
|
||||
}
|
||||
|
||||
/** A stop that can carry its name into a deep link that has somewhere to put one. */
|
||||
export type NamedWaypoint = Waypoint & { name?: string | null }
|
||||
|
||||
/** TREK's route profiles in CoMaps' vocabulary; a plugin profile has no equivalent and drives. */
|
||||
function coMapsRouteType(profile: RouteProfileKey): string {
|
||||
if (profile === 'walking') return 'pedestrian'
|
||||
if (profile === 'cycling') return 'bicycle'
|
||||
return 'vehicle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a day's stops in CoMaps for offline navigation (#1904).
|
||||
*
|
||||
* CoMaps has two links and they trade against each other. `route` builds real
|
||||
* turn-by-turn in the given travel mode but takes a start and a destination and
|
||||
* nothing between them; `map` takes any number of named pins but routes nothing.
|
||||
* So a two-stop day goes as a route — everything it has fits, mode included —
|
||||
* and a longer one goes as pins, because handing over the whole day and letting
|
||||
* CoMaps route leg by leg beats quietly dropping the middle of someone's plan.
|
||||
* A day that needs the full itinerary as one navigable track has the GPX export.
|
||||
*
|
||||
* https rather than `cm://` for the same reason as `getCoMapsUrlForPlace`.
|
||||
*/
|
||||
export function generateCoMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null {
|
||||
const valid = places.filter((p) => p.lat != null && p.lng != null)
|
||||
if (valid.length === 0) return null
|
||||
const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || `${p.lat},${p.lng}`)
|
||||
if (valid.length === 2) {
|
||||
const [from, to] = valid
|
||||
return `https://comaps.at/route?sll=${from.lat},${from.lng}&saddr=${label(from)}`
|
||||
+ `&dll=${to.lat},${to.lng}&daddr=${label(to)}&type=${coMapsRouteType(profile)}`
|
||||
}
|
||||
const pins = valid.map((p) => `ll=${p.lat},${p.lng}&n=${label(p)}`).join('&')
|
||||
return `https://comaps.at/map?v=1&${pins}`
|
||||
}
|
||||
|
||||
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine.
|
||||
function sqDist(a: Waypoint, b: Waypoint): number {
|
||||
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-012
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function
|
||||
// `downloadJourneyBookPDF(journey, { t, locale })` that renders a PDF preview in
|
||||
// an srcdoc iframe overlay (Safari-safe pattern). Tests verify the overlay DOM
|
||||
// structure, the HTML content and that the book follows the app language.
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
// Mock `marked` so we don't need the real markdown parser
|
||||
vi.mock('marked', () => ({
|
||||
marked: {
|
||||
parse: (str: string) => `<p>${str}</p>`,
|
||||
},
|
||||
}));
|
||||
|
||||
import { downloadJourneyBookPDF } from './JourneyBookPDF';
|
||||
import type { JourneyDetail } from '../../store/journeyStore';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
title: 'Iceland Ring Road',
|
||||
subtitle: 'Two weeks around the island',
|
||||
status: 'active',
|
||||
cover_image: null,
|
||||
cover_gradient: null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
entries: [
|
||||
{
|
||||
id: 10,
|
||||
journey_id: 1,
|
||||
author_id: 1,
|
||||
type: 'entry',
|
||||
title: 'Golden Circle',
|
||||
story: 'An incredible day of geysers and waterfalls.',
|
||||
entry_date: '2026-07-01',
|
||||
entry_time: '09:00',
|
||||
location_name: 'Thingvellir',
|
||||
location_lat: 64.255,
|
||||
location_lng: -21.13,
|
||||
mood: 'excited',
|
||||
weather: 'sunny',
|
||||
tags: [],
|
||||
pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] },
|
||||
visibility: 'private',
|
||||
sort_order: 0,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
source_trip_id: null,
|
||||
source_place_id: null,
|
||||
source_trip_name: null,
|
||||
photos: [
|
||||
{
|
||||
id: 100,
|
||||
entry_id: 10,
|
||||
provider: 'local',
|
||||
file_path: 'journey/geyser.jpg',
|
||||
thumbnail_path: null,
|
||||
asset_id: null,
|
||||
owner_id: null,
|
||||
shared: 0,
|
||||
caption: 'Strokkur erupting',
|
||||
sort_order: 0,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
trips: [],
|
||||
contributors: [],
|
||||
stats: { entries: 1, photos: 1, cities: 1 },
|
||||
...overrides,
|
||||
} as unknown as JourneyDetail;
|
||||
}
|
||||
|
||||
// ── Helpers to inspect the overlay ───────────────────────────────────────────
|
||||
|
||||
function getOverlay(): HTMLElement | null {
|
||||
return document.getElementById('journey-pdf-overlay');
|
||||
}
|
||||
|
||||
function getIframe(): HTMLIFrameElement | null {
|
||||
return getOverlay()?.querySelector('iframe') ?? null;
|
||||
}
|
||||
|
||||
// Echoes the key (plus any params) so the assertions read as "this string comes
|
||||
// from i18n" instead of pinning English copy.
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return key;
|
||||
return `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})`;
|
||||
}
|
||||
|
||||
const longDate = (d: string, locale: string) =>
|
||||
new Date(d + 'T00:00:00').toLocaleDateString(locale, {
|
||||
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
|
||||
});
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('journey-pdf-overlay')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
expect(document.body.contains(getOverlay())).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const iframe = getIframe();
|
||||
expect(iframe).not.toBeNull();
|
||||
const html = iframe!.srcdoc;
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const overlay = getOverlay()!;
|
||||
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
|
||||
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Golden Circle');
|
||||
// Story text is rendered via markdown
|
||||
expect(html).toContain('An incredible day of geysers and waterfalls.');
|
||||
// Pros/cons verdict cards are included
|
||||
expect(html).toContain('Amazing views');
|
||||
expect(html).toContain('Crowded');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
|
||||
const journey = buildJourney({ entries: [] });
|
||||
await downloadJourneyBookPDF(journey);
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present. Without a
|
||||
// translator the chrome falls back to the raw keys instead of throwing.
|
||||
expect(html).toContain('journey.pdf.journeyBook');
|
||||
expect(html).toContain('journey.pdf.theEnd');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
|
||||
const journey = buildJourney();
|
||||
journey.entries[0].story = 'Hello <script>alert(1)</script> <img src=x onerror="alert(2)"> world';
|
||||
await downloadJourneyBookPDF(journey);
|
||||
const iframe = getIframe()!;
|
||||
const html = iframe.srcdoc;
|
||||
|
||||
// The script tag, image beacon and event handler are stripped from the story.
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html).not.toContain('alert(2)');
|
||||
// Benign prose survives.
|
||||
expect(html).toContain('Hello');
|
||||
expect(html).toContain('world');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-008: renders cover, day header and closing page through i18n', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
|
||||
const html = getIframe()!.srcdoc;
|
||||
|
||||
expect(html).toContain('<html lang="de">');
|
||||
expect(html).toContain('journey.pdf.journeyBook');
|
||||
expect(html).toContain('journey.stats.days');
|
||||
expect(html).toContain('journey.stats.entries');
|
||||
expect(html).toContain('journey.stats.photos');
|
||||
expect(html).toContain('journey.pdf.madeWith');
|
||||
expect(html).toContain('journey.pdf.theEnd');
|
||||
// Day header is the parameterised key plus a date in the picked locale —
|
||||
// the hardcoded 'en' formatting would not produce this string.
|
||||
expect(html).toContain('journey.detail.day(number=1)');
|
||||
expect(html).toContain(longDate('2026-07-01', 'de-DE'));
|
||||
// Pros/cons headings too.
|
||||
expect(html).toContain('journey.verdict.lovedIt');
|
||||
expect(html).toContain('journey.verdict.couldBeBetter');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-009: translates the preview bar above the book', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
|
||||
const overlay = getOverlay()!;
|
||||
|
||||
// cover + one entry page + closing page
|
||||
expect(overlay.textContent).toContain('3 journey.pdf.pages');
|
||||
expect(overlay.querySelector('#journey-pdf-save')!.textContent).toBe('journey.pdf.saveAsPdf');
|
||||
expect(overlay.querySelector('#journey-pdf-close')!.textContent).toBe('common.close');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-010: prints mood and weather next to the entry', async () => {
|
||||
const journey = buildJourney();
|
||||
journey.entries[0].mood = 'good';
|
||||
journey.entries[0].weather = 'rainy';
|
||||
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
|
||||
const html = getIframe()!.srcdoc;
|
||||
|
||||
expect(html).toContain('journey.mood.good');
|
||||
expect(html).toContain('journey.weather.rainy');
|
||||
// Icons come along as inline SVG inside the chips.
|
||||
expect(html).toContain('class="entry-chips"');
|
||||
expect(html).toContain('<svg');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-011: skips chips for values the app does not know', async () => {
|
||||
// The fixture carries mood 'excited', which is not one of the four moods.
|
||||
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'en-US' });
|
||||
const html = getIframe()!.srcdoc;
|
||||
// Scoped to the chip row: the page elsewhere is free to carry whatever the
|
||||
// fixture happens to leave undefined, and that is not what this asserts.
|
||||
const chips = html.match(/<div class="entry-chips">[\s\S]*?<\/div>/)?.[0] ?? '';
|
||||
|
||||
expect(chips).not.toBe('');
|
||||
expect(chips).not.toContain('undefined');
|
||||
expect(chips).not.toContain('journey.mood.');
|
||||
// The known weather value still gets its chip.
|
||||
expect(chips).toContain('journey.weather.sunny');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-012: leaves out the chip row when an entry has neither', async () => {
|
||||
const journey = buildJourney();
|
||||
journey.entries[0].mood = null;
|
||||
journey.entries[0].weather = null;
|
||||
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
|
||||
|
||||
expect(getIframe()!.srcdoc).not.toContain('class="entry-chips"');
|
||||
});
|
||||
});
|
||||
@@ -1,391 +0,0 @@
|
||||
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
|
||||
import { createElement } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeRichTextHtml } from '@trek/shared'
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
function esc(str: string | null | undefined): string {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function md(str: string | null | undefined): string {
|
||||
if (!str) return ''
|
||||
// marked passes embedded raw HTML through by default, so sanitise the result
|
||||
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
|
||||
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
|
||||
}
|
||||
|
||||
function abs(url: string | null | undefined): string {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
|
||||
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
|
||||
}
|
||||
|
||||
function pSrc(p: JourneyPhoto): string {
|
||||
return abs(`/api/photos/${p.photo_id}/original`)
|
||||
}
|
||||
|
||||
function fmtDate(d: string, locale: string): string {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return date.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
const groups = new Map<string, JourneyEntry[]>()
|
||||
for (const e of entries) {
|
||||
if (!e.entry_date) continue
|
||||
if (!groups.has(e.entry_date)) groups.set(e.entry_date, [])
|
||||
groups.get(e.entry_date)!.push(e)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function renderProscons(entry: JourneyEntry, tr: Translate): string {
|
||||
const pc = entry.pros_cons
|
||||
if (!pc) return ''
|
||||
const pros = pc.pros?.filter(p => p.trim()) || []
|
||||
const cons = pc.cons?.filter(c => c.trim()) || []
|
||||
if (pros.length === 0 && cons.length === 0) return ''
|
||||
|
||||
return `<div class="verdict-wrap"><div class="verdict-row">
|
||||
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">${esc(tr('journey.verdict.lovedIt'))}</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
|
||||
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">${esc(tr('journey.verdict.couldBeBetter'))}</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
|
||||
</div></div>`
|
||||
}
|
||||
|
||||
function chip(icon: LucideIcon, label: string, bg: string, color: string): string {
|
||||
const svg = renderIconMarkup(createElement(icon, { size: 12, strokeWidth: 2, color }))
|
||||
return `<span class="chip" style="background:${bg};color:${color}">${svg}${esc(label)}</span>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood and weather were on screen but never in the book (#1848). Unknown values
|
||||
* (older entries, a provider-set category we don't render) drop out silently,
|
||||
* the same way MoodChip/WeatherChip return null for them.
|
||||
*/
|
||||
function renderMoodWeather(entry: JourneyEntry, tr: Translate): string {
|
||||
const chips: string[] = []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : undefined
|
||||
if (mood) chips.push(chip(mood.icon, tr(mood.label), mood.bg, mood.text))
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : undefined
|
||||
if (weather) chips.push(chip(weather.icon, tr(weather.label), '#f4f4f5', '#3f3f46'))
|
||||
if (chips.length === 0) return ''
|
||||
return `<div class="entry-chips">${chips.join('')}</div>`
|
||||
}
|
||||
|
||||
function renderPhotoBlock(photos: JourneyPhoto[]): string {
|
||||
if (photos.length === 0) return ''
|
||||
if (photos.length === 1) {
|
||||
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
|
||||
}
|
||||
if (photos.length === 2) {
|
||||
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
|
||||
}
|
||||
// 3+ photos: hero left + stack right
|
||||
return `<div class="entry-photo-trio">
|
||||
<div class="photo-hero"><img src="${pSrc(photos[0])}" /></div>
|
||||
<div class="photo-stack">
|
||||
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
|
||||
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* `opts` stays optional so the signature is additive, but every caller passes
|
||||
* the page's `t`/`locale` — without them the book falls back to raw keys (#1848).
|
||||
*/
|
||||
export async function downloadJourneyBookPDF(
|
||||
journey: JourneyDetail,
|
||||
opts: { t?: Translate; locale?: string } = {},
|
||||
) {
|
||||
const tr: Translate = opts.t || (key => key)
|
||||
const loc = opts.locale || 'en'
|
||||
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton')
|
||||
const allPhotos = entries.flatMap(e => e.photos || [])
|
||||
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
|
||||
|
||||
const grouped = groupByDate(entries)
|
||||
const dates = [...grouped.keys()].sort()
|
||||
|
||||
// Build entry pages — one per entry, day header inline on first entry of day
|
||||
const entryPages: string[] = []
|
||||
let pageNum = 1 // cover=1
|
||||
dates.forEach((date, di) => {
|
||||
const dayEntries = grouped.get(date)!
|
||||
dayEntries.forEach((entry, ei) => {
|
||||
pageNum++
|
||||
const isFirstOfDay = ei === 0
|
||||
const photos = entry.photos || []
|
||||
const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ')
|
||||
|
||||
// Day header (inline, only on first entry of day)
|
||||
const dayHeaderHtml = isFirstOfDay
|
||||
? `<div class="day-header">${esc(tr('journey.detail.day', { number: di + 1 }))} · ${esc(fmtDate(date, loc))}</div>`
|
||||
: ''
|
||||
|
||||
// Photo block
|
||||
const photoHtml = renderPhotoBlock(photos)
|
||||
|
||||
// Pros/cons
|
||||
const prosconsHtml = renderProscons(entry, tr)
|
||||
|
||||
// Story (markdown)
|
||||
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
|
||||
|
||||
entryPages.push(`
|
||||
<div class="entry-page">
|
||||
${dayHeaderHtml}
|
||||
${photoHtml}
|
||||
<div class="entry-content">
|
||||
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
|
||||
${renderMoodWeather(entry, tr)}
|
||||
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
|
||||
${storyHtml}
|
||||
${prosconsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = pageNum + 1 // +1 for closing page
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${esc(loc.split('-')[0])}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- The page is A4 landscape and laid out in pt, so it is about 1123px wide. Without
|
||||
this, a phone lays the preview out at its default 980px and the document spills
|
||||
out of the frame, which reads as being zoomed far in. Declaring the real width
|
||||
makes the browser scale the whole page to fit instead. Printing is unaffected,
|
||||
it goes by @page. -->
|
||||
<meta name="viewport" content="width=1123">
|
||||
<base href="${window.location.origin}/">
|
||||
<title>${esc(journey.title)} — ${esc(tr('journey.pdf.journeyBook'))}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; color: #1A1A1A; font-size: 11pt; line-height: 1.55; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
|
||||
/* ── Cover ─── */
|
||||
.cover-page {
|
||||
width: 100%; height: 100vh; position: relative; overflow: hidden;
|
||||
background: #0a0a0f; color: white; display: flex; align-items: center; justify-content: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
|
||||
.cover-dim { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
|
||||
.cover-mesh { position: absolute; inset: 0; background: radial-gradient(circle at 20% 30%, rgba(99,102,241,0.2), transparent 50%), radial-gradient(circle at 80% 70%, rgba(236,72,153,0.15), transparent 50%); }
|
||||
.cover-content { position: relative; z-index: 2; text-align: center; padding: 60pt; }
|
||||
.cover-label { font-size: 9pt; font-weight: 700; letter-spacing: 6pt; text-transform: uppercase; opacity: 0.35; margin-bottom: 24pt; }
|
||||
.cover-content h1 { font-size: 56pt; font-weight: 800; letter-spacing: -0.03em; line-height: 0.9; margin-bottom: 10pt; }
|
||||
.cover-content .sub { font-size: 14pt; font-weight: 400; opacity: 0.7; margin-bottom: 36pt; }
|
||||
.cover-stats { display: flex; gap: 48pt; justify-content: center; }
|
||||
.cover-stat-val { font-size: 32pt; font-weight: 800; letter-spacing: -0.02em; }
|
||||
.cover-stat-label { font-size: 10pt; text-transform: uppercase; letter-spacing: 2pt; opacity: 0.4; margin-top: 3pt; }
|
||||
.cover-footer { position: absolute; bottom: 20pt; left: 0; right: 0; text-align: center; font-size: 9pt; opacity: 0.2; letter-spacing: 3pt; text-transform: uppercase; }
|
||||
|
||||
/* ── TOC ─── */
|
||||
.toc-page {
|
||||
width: 100%; height: 100vh; padding: 48pt 64pt; display: flex; flex-direction: column;
|
||||
background: white; page-break-after: always;
|
||||
}
|
||||
.toc-top-label { font-size: 9pt; font-weight: 700; letter-spacing: 5pt; text-transform: uppercase; color: #94a3b8; margin-bottom: 16pt; }
|
||||
.toc-title-block h2 { font-size: 36pt; font-weight: 800; letter-spacing: -1pt; color: #0a0a0f; margin-bottom: 4pt; }
|
||||
.toc-title-block .sub { font-size: 13pt; color: #71717a; margin-bottom: 24pt; }
|
||||
.toc-divider { height: 1pt; background: #e4e4e7; margin: 16pt 0; }
|
||||
.toc-body { flex: 1; columns: 2; column-gap: 40pt; }
|
||||
.toc-day { break-inside: avoid; margin-bottom: 14pt; }
|
||||
.toc-day-label { font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: #71717a; margin-bottom: 4pt; }
|
||||
.toc-entry { display: flex; align-items: baseline; gap: 4pt; font-size: 11pt; color: #3f3f46; margin-bottom: 2pt; }
|
||||
.toc-entry .toc-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200pt; }
|
||||
.toc-entry .toc-dots { flex: 1; border-bottom: 1pt dotted #d4d4d8; margin: 0 4pt; min-width: 20pt; }
|
||||
.toc-entry .toc-page { font-size: 10pt; color: #a1a1aa; font-weight: 500; flex-shrink: 0; }
|
||||
.toc-stats { display: flex; gap: 32pt; margin-top: auto; padding-top: 16pt; border-top: 1pt solid #e4e4e7; }
|
||||
.toc-stat-val { font-size: 18pt; font-weight: 800; color: #0a0a0f; }
|
||||
.toc-stat-label { font-size: 9pt; text-transform: uppercase; letter-spacing: 1pt; color: #94a3b8; }
|
||||
|
||||
/* ── Entry Page ─── */
|
||||
.entry-page {
|
||||
width: 100%; min-height: 100vh; padding: 56pt 48pt 48pt;
|
||||
page-break-after: always;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* Day header — inline */
|
||||
.day-header {
|
||||
font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: #71717a; text-align: center; margin-bottom: 16pt; position: relative;
|
||||
display: flex; align-items: center; gap: 12pt;
|
||||
}
|
||||
.day-header::before, .day-header::after { content: ''; flex: 1; height: 0.5pt; background: #d4d4d8; }
|
||||
|
||||
/* Photos */
|
||||
.entry-photo-single { border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 55vh; }
|
||||
.entry-photo-single img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.entry-photo-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 45vh; }
|
||||
.entry-photo-trio { display: grid; grid-template-columns: 3fr 2fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 50vh; }
|
||||
.photo-cell { overflow: hidden; }
|
||||
.photo-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.photo-hero { overflow: hidden; }
|
||||
.photo-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.photo-stack { display: flex; flex-direction: column; gap: 6pt; }
|
||||
.photo-stack .photo-cell { flex: 1; }
|
||||
|
||||
/* Entry content */
|
||||
.entry-content { flex: 1; }
|
||||
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
|
||||
.entry-chips { display: flex; flex-wrap: wrap; gap: 5pt; margin-bottom: 8pt; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3pt; padding: 2.5pt 7pt; border-radius: 999pt; font-size: 8.5pt; font-weight: 600; }
|
||||
.chip svg { width: 9pt; height: 9pt; }
|
||||
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
|
||||
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
|
||||
.entry-story p { margin: 0 0 8pt; }
|
||||
.entry-story strong { font-weight: 600; color: #0a0a0f; }
|
||||
.entry-story em { font-style: italic; }
|
||||
.entry-story blockquote { margin: 12pt 0; padding-left: 12pt; border-left: 2pt solid #d4d4d8; font-style: italic; color: #52525b; }
|
||||
.entry-story ul, .entry-story ol { margin: 8pt 0; padding-left: 16pt; }
|
||||
.entry-story li { margin-bottom: 4pt; }
|
||||
.entry-story a { color: #2563eb; text-decoration: none; }
|
||||
|
||||
/* Verdict */
|
||||
.verdict-wrap { break-inside: avoid; padding-top: 14pt; }
|
||||
.verdict-row { display: flex; gap: 10pt; }
|
||||
.verdict-card { flex: 1; padding: 10pt 12pt; border-radius: 6pt; font-size: 9.5pt; }
|
||||
.verdict-card.pros { background: #f0fdf4; border: 0.5pt solid #bbf7d0; }
|
||||
.verdict-card.cons { background: #fef2f2; border: 0.5pt solid #fecaca; }
|
||||
.verdict-label { font-size: 8pt; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6pt; }
|
||||
.verdict-card.pros .verdict-label { color: #15803d; }
|
||||
.verdict-card.cons .verdict-label { color: #b91c1c; }
|
||||
.verdict-card ul { margin: 0; padding: 0; list-style: none; }
|
||||
.verdict-card li { padding: 2pt 0; position: relative; padding-left: 10pt; }
|
||||
.verdict-card li::before { content: '•'; position: absolute; left: 0; }
|
||||
.verdict-card.pros li { color: #14532d; }
|
||||
.verdict-card.pros li::before { color: #22c55e; }
|
||||
.verdict-card.cons li { color: #7f1d1d; }
|
||||
.verdict-card.cons li::before { color: #ef4444; }
|
||||
|
||||
/* ── Closing ─── */
|
||||
.closing-page {
|
||||
width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: #0a0a0f; color: white; text-align: center; page-break-after: auto;
|
||||
}
|
||||
.closing-title { font-size: 32pt; font-weight: 300; letter-spacing: -1pt; opacity: 0.6; margin-bottom: 8pt; }
|
||||
.closing-sub { font-size: 10pt; opacity: 0.25; letter-spacing: 3pt; text-transform: uppercase; }
|
||||
|
||||
/* ── Print ─── */
|
||||
@media print {
|
||||
.print-bar { display: none !important; }
|
||||
body { margin: 0; }
|
||||
.entry-page { orphans: 3; widows: 3; }
|
||||
h2.entry-title { page-break-after: avoid; }
|
||||
.verdict-row { page-break-inside: avoid; }
|
||||
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Page 1: Cover -->
|
||||
<div class="cover-page">
|
||||
${coverUrl ? `<div class="cover-bg" style="background-image:url('${coverUrl}')"></div>` : ''}
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-mesh"></div>
|
||||
<div class="cover-content">
|
||||
<div class="cover-label">${esc(tr('journey.pdf.journeyBook'))}</div>
|
||||
<h1>${esc(journey.title)}</h1>
|
||||
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
|
||||
<div class="cover-stats">
|
||||
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.days'))}</div></div>
|
||||
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.entries'))}</div></div>
|
||||
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.photos'))}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cover-footer">${esc(tr('journey.pdf.madeWith'))}</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry Pages -->
|
||||
${entryPages.join('\n')}
|
||||
|
||||
<!-- Closing Page -->
|
||||
<div class="closing-page">
|
||||
<div>
|
||||
<div class="closing-title">${esc(tr('journey.pdf.theEnd'))}</div>
|
||||
<div class="closing-sub">${esc(tr('journey.pdf.madeWith'))} · ${new Date().getFullYear()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
|
||||
// This avoids window.open() which Safari iOS blocks in async callbacks
|
||||
// and window.close() which doesn't work reliably in standalone PWA mode.
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'journey-pdf-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
// The chrome needs a stylesheet rather than inline styles: on a phone the title
|
||||
// and the two labels do not fit on one row, and translated labels are longer
|
||||
// than the English ones. Below 640px the card goes full-bleed, the decorative
|
||||
// title steps aside and the two actions share the width (#1848).
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
#journey-pdf-overlay .jp-card { width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); }
|
||||
#journey-pdf-overlay .jp-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a; }
|
||||
#journey-pdf-overlay .jp-title { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; }
|
||||
#journey-pdf-overlay .jp-actions { display:flex;align-items:center;gap:8px;flex:none; }
|
||||
#journey-pdf-overlay .jp-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; }
|
||||
#journey-pdf-overlay .jp-save { border:none;background:#fff;color:#0f172a; }
|
||||
#journey-pdf-overlay .jp-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); }
|
||||
@media (max-width: 640px) {
|
||||
#journey-pdf-overlay { padding:0; }
|
||||
#journey-pdf-overlay .jp-card { height:100dvh;max-width:none;border-radius:0; }
|
||||
#journey-pdf-overlay .jp-head { padding:8px 10px; }
|
||||
#journey-pdf-overlay .jp-title { display:none; }
|
||||
#journey-pdf-overlay .jp-actions { flex:1; }
|
||||
#journey-pdf-overlay .jp-btn { flex:1;padding:10px 12px;font-size:13px; }
|
||||
}
|
||||
`
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.className = 'jp-card'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.className = 'jp-head'
|
||||
header.innerHTML = `
|
||||
<span class="jp-title">${esc(journey.title)} · ${totalPages} ${esc(tr('journey.pdf.pages'))}</span>
|
||||
<div class="jp-actions">
|
||||
<button id="journey-pdf-save" class="jp-btn jp-save">${esc(tr('journey.pdf.saveAsPdf'))}</button>
|
||||
<button id="journey-pdf-close" class="jp-btn jp-close">${esc(tr('common.close'))}</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
// No script runs inside the document (print is triggered from the parent via
|
||||
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(style)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw'
|
||||
import { downloadTripPDF } from './TripPDF'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
|
||||
import { getMergedItems, getTransportForDay } from '../../utils/dayMerge'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -548,7 +549,7 @@ describe('downloadTripPDF', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FE-W5PDF-001 to FE-W5PDF-018 — multi-day transport spans, the remaining
|
||||
// FE-W5PDF-001 to FE-W5PDF-030 — multi-day transport spans, the remaining
|
||||
// reservation subtitles, accommodation phases and cover fallbacks.
|
||||
describe('downloadTripPDF remaining branches', () => {
|
||||
const dA = { id: 10, day_number: 1, title: 'Day A', date: '2025-06-01' } as any
|
||||
@@ -603,6 +604,20 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
expect(html).not.toContain('reservations.span.active')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-029: a multi-day parking is labelled drop-off/pickup and skips the middle day (#1937)', async () => {
|
||||
await downloadTripPDF(spanArgs([{
|
||||
id: 506, title: 'Airport Parking', type: 'parking', day_id: 10, end_day_id: 12,
|
||||
reservation_time: '2025-06-01T05:30', reservation_end_time: '2025-06-03T19:00',
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('reservations.span.dropOff: Airport Parking')
|
||||
expect(html).toContain('reservations.span.pickup: Airport Parking')
|
||||
expect(html).not.toContain('reservations.span.ongoing')
|
||||
// Day B holds nothing else, so it prints the empty-day hint rather than the booking.
|
||||
expect(html).toContain('dayplan.emptyDay')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-004: hotels, day-less and unknown-day reservations are all skipped', async () => {
|
||||
await downloadTripPDF(spanArgs([
|
||||
{ id: 503, title: 'Hotel Row', type: 'hotel', day_id: 10 },
|
||||
@@ -691,6 +706,25 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
expect(html).toContain('AirX · X2 · AAA → BBB')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-030: a stopover flight prints the booking code of each segment that has one (#1943)', async () => {
|
||||
await downloadTripPDF(spanArgs([{
|
||||
id: 518, title: 'Layover', type: 'flight', day_id: 10, confirmation_number: 'BOOK1',
|
||||
metadata: JSON.stringify({
|
||||
legs: [
|
||||
{ airline: 'LH', flight_number: 'LH1', from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
|
||||
{ airline: 'ANA', flight_number: 'NH2', from: 'BER', to: 'HND' },
|
||||
],
|
||||
}),
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('LH · LH1 · FRA → BER · ABC123')
|
||||
// A segment without one keeps the line it had before, and the booking's own
|
||||
// reference still prints once for the whole card.
|
||||
expect(html).toContain('ANA · NH2 · BER → HND')
|
||||
expect(html).toContain('Code: BOOK1')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-010: a single-leg flight with waypoints joins the whole route', async () => {
|
||||
await downloadTripPDF(spanArgs([{
|
||||
id: 517, title: 'Long Haul', type: 'flight', day_id: 10,
|
||||
@@ -1063,3 +1097,114 @@ describe('page breaks between days (#1292)', () => {
|
||||
expect(html.slice(at - 10, at)).toContain('.pdf-flow ')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The order a day prints in (#1978).
|
||||
*
|
||||
* The export used to order a day by itself, on `day_plan_position` — one global
|
||||
* number per booking, auto-seeded from whichever day happened to render first.
|
||||
* On any other day of a span it therefore pointed at the wrong slot, and where
|
||||
* it was still null the export fell back to the order the API returned rows in.
|
||||
* That is why the same trip printed correctly some of the time and swapped two
|
||||
* bookings the rest of it, and why adding a place and removing it again "fixed"
|
||||
* it: the reseed happened to land on the day being looked at.
|
||||
*
|
||||
* It now goes through utils/dayMerge, the same functions the day plan uses, so
|
||||
* the two cannot disagree. These cases are written from the plan's rules rather
|
||||
* than from the old implementation's.
|
||||
*/
|
||||
describe('a printed day follows the plan (#1978)', () => {
|
||||
const d1 = { id: 21, day_number: 1, title: 'Day One', date: '2025-06-01' } as any
|
||||
const d2 = { id: 22, day_number: 2, title: 'Day Two', date: '2025-06-02' } as any
|
||||
const srcdoc = () => getIframe()!.srcdoc
|
||||
|
||||
/** Where each needle first appears, so order can be asserted without parsing. */
|
||||
const positions = (html: string, needles: string[]) => needles.map(n => html.indexOf(n))
|
||||
|
||||
it('orders the day by the clock, not by the order the API sent rows in', async () => {
|
||||
await downloadTripPDF({
|
||||
...minimalArgs,
|
||||
trip: { id: 21, title: 'Order Trip', description: null, cover_image: null } as any,
|
||||
days: [d1],
|
||||
// As the API returns them: reservation_time ASC with nulls first.
|
||||
reservations: [
|
||||
{ id: 1, title: 'Untimed Transfer', type: 'transfer', day_id: 21, reservation_time: null },
|
||||
{ id: 2, title: 'Evening Train', type: 'train', day_id: 21, reservation_time: '2025-06-01T19:30' },
|
||||
{ id: 3, title: 'Morning Flight', type: 'flight', day_id: 21, reservation_time: '2025-06-01T07:15' },
|
||||
],
|
||||
})
|
||||
const [morning, evening] = positions(srcdoc(), ['Morning Flight', 'Evening Train'])
|
||||
expect(morning).toBeGreaterThan(-1)
|
||||
expect(evening).toBeGreaterThan(-1)
|
||||
expect(morning).toBeLessThan(evening)
|
||||
})
|
||||
|
||||
/*
|
||||
* The reported shape: a booking on the last day of a span whose global
|
||||
* position was seeded from a busier earlier day, which sank it below
|
||||
* everything on the day it actually belongs to.
|
||||
*/
|
||||
it('ignores a global position seeded from another day', async () => {
|
||||
await downloadTripPDF({
|
||||
...minimalArgs,
|
||||
trip: { id: 22, title: 'Span Trip', description: null, cover_image: null } as any,
|
||||
days: [d1, d2],
|
||||
reservations: [
|
||||
{
|
||||
id: 4, title: 'Car Return', type: 'car', day_id: 21, end_day_id: 22,
|
||||
reservation_time: '2025-06-01T10:00', reservation_end_time: '2025-06-02T09:00',
|
||||
// Seeded from day one, where five places had already been placed.
|
||||
day_plan_position: 5,
|
||||
},
|
||||
{ id: 5, title: 'Late Flight', type: 'flight', day_id: 22, reservation_time: '2025-06-02T18:00' },
|
||||
],
|
||||
})
|
||||
const html = srcdoc()
|
||||
// On day two the 09:00 return comes before the 18:00 flight, whatever the
|
||||
// stale global position says.
|
||||
const [ret, flight] = positions(html, ['Car Return', 'Late Flight'])
|
||||
expect(ret).toBeGreaterThan(-1)
|
||||
expect(flight).toBeGreaterThan(-1)
|
||||
expect(ret).toBeLessThan(flight)
|
||||
})
|
||||
|
||||
/*
|
||||
* The strongest form of the same claim: whatever the plan would show, the
|
||||
* print shows, in that order. Asserted against getMergedItems itself rather
|
||||
* than against a hand-written expectation, so the two cannot drift apart
|
||||
* again without this failing.
|
||||
*/
|
||||
it('prints a mixed day in exactly the order the plan merges it', async () => {
|
||||
const reservations = [
|
||||
{ id: 8, title: 'Untimed Transfer', type: 'transfer', day_id: 21, reservation_time: null },
|
||||
{ id: 9, title: 'Evening Train', type: 'train', day_id: 21, reservation_time: '2025-06-01T19:30' },
|
||||
{ id: 10, title: 'Morning Flight', type: 'flight', day_id: 21, reservation_time: '2025-06-01T07:15' },
|
||||
]
|
||||
const dayAssignments = [
|
||||
{ id: 100, day_id: 21, order_index: 0, place: { id: 1, name: 'Museum', place_time: '11:00' } },
|
||||
{ id: 101, day_id: 21, order_index: 1, place: { id: 2, name: 'Market', place_time: '16:00' } },
|
||||
]
|
||||
|
||||
await downloadTripPDF({
|
||||
...minimalArgs,
|
||||
trip: { id: 24, title: 'Mixed Trip', description: null, cover_image: null } as any,
|
||||
days: [d1],
|
||||
assignments: { '21': dayAssignments } as any,
|
||||
reservations,
|
||||
})
|
||||
const html = srcdoc()
|
||||
|
||||
const expected = getMergedItems({
|
||||
dayAssignments,
|
||||
dayNotes: [],
|
||||
dayTransports: getTransportForDay({
|
||||
reservations, dayId: 21, dayAssignmentIds: dayAssignments.map(a => a.id), days: [d1],
|
||||
}),
|
||||
dayId: 21,
|
||||
}).map(item => (item.type === 'place' ? item.data.place.name : item.data.title))
|
||||
|
||||
const seen = expected.map(name => html.indexOf(name))
|
||||
for (const at of seen) expect(at).toBeGreaterThan(-1)
|
||||
expect(seen).toEqual([...seen].sort((a, b) => a - b))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
|
||||
import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { formatMoney, formatMoneySum, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
|
||||
@@ -208,67 +209,65 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
const fxRates = needsFx ? await fetchExchangeRates(tripCur) : null
|
||||
const totalCostLabel = formatMoneySum(allCostEntries, tripCur, loc || 'en', fxRates)
|
||||
|
||||
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (!startId || startId === endId) return 'single'
|
||||
if (dayId === startId) return 'start'
|
||||
if (dayId === endId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||
const phase = pdfGetSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
/*
|
||||
* The span label is the only PDF-specific piece left here. Everything else
|
||||
* about how a day is ordered — which transports belong to it, what time each
|
||||
* one shows, where it sits among the places — comes from utils/dayMerge, the
|
||||
* same functions the day plan itself uses (#1978).
|
||||
*
|
||||
* There used to be a second implementation in this file. It ordered by
|
||||
* `day_plan_position`, which is one global number per booking, seeded from
|
||||
* whichever day happened to render first; on any other day of a span it
|
||||
* pointed at the wrong slot, and when it was still null the export fell back
|
||||
* to the order the API returned. That is why the PDF was right some of the
|
||||
* time and swapped two bookings the rest of it.
|
||||
*/
|
||||
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||
// Parking reuses span.pickup for its end day: you drop the car off and collect it
|
||||
// again later, so the rental car's wording carries over without a second key.
|
||||
if (r.type === 'parking') return tr(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`)
|
||||
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId == null) return false
|
||||
if (endId !== startId) {
|
||||
const startDay = sorted.find(d => d.id === startId)
|
||||
const endDay = sorted.find(d => d.id === endId)
|
||||
const thisDay = sorted.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||
}
|
||||
return startId === dayId
|
||||
})
|
||||
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
const assigned = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const assigned = (assignments[String(day.id)] || []).slice()
|
||||
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
|
||||
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
|
||||
|
||||
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||
const dayReservations = pdfGetTransportForDay(day.id)
|
||||
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'reservation', k: pos, data: r })
|
||||
// Assembled exactly the way DayPlanSidebar assembles it, so the page and the
|
||||
// print cannot disagree about what order a day is in.
|
||||
const dayTransports = getTransportForDay({
|
||||
reservations: reservations || [],
|
||||
dayId: day.id,
|
||||
dayAssignmentIds: assigned.map((a: any) => a.id),
|
||||
days: sorted,
|
||||
})
|
||||
|
||||
// Hotels come out in the accommodations block, a rental car's middle days
|
||||
// appear only in the sidebar header, and a multi-day parking's middle days
|
||||
// nowhere at all.
|
||||
const merged = getMergedItems({
|
||||
dayAssignments: assigned,
|
||||
dayNotes: notes,
|
||||
dayTransports,
|
||||
dayId: day.id,
|
||||
}).filter(item => {
|
||||
if (item.type !== 'transport') return true
|
||||
const r: any = item.data
|
||||
if (r.type === 'car' && getSpanPhase(r, day.id) === 'middle') return false
|
||||
return !hidesOnMiddleDay(r, day.id)
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
|
||||
let pi = 0
|
||||
const itemsHtml = merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged.map(item => {
|
||||
if (item.type === 'reservation') {
|
||||
if (item.type === 'transport') {
|
||||
const r = item.data
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const icon = reservationIconSvg(r.type)
|
||||
@@ -276,13 +275,39 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
let subtitle = ''
|
||||
// Flights render one subtitle line per leg (see below); everything else is a single line.
|
||||
let subtitleLines: string[] = []
|
||||
if (r.type === 'flight') {
|
||||
/*
|
||||
* A multi-leg booking arrives as one item per leg now, the way the
|
||||
* day plan shows it, because the shared merge expands legs so each
|
||||
* one can sit at its own time among the day's places. Previously
|
||||
* the export printed a single row with every leg as a subtitle
|
||||
* line, which put the second leg's departure next to the first
|
||||
* leg's clock.
|
||||
*/
|
||||
if (r.__leg) {
|
||||
const l = r.__leg
|
||||
// The segment's own booking code, which the leg object does not
|
||||
// carry — read back from the booking by index. At the gate that is
|
||||
// the code the airline asks for (#1943), so losing it here would
|
||||
// have been a real regression for a stopover flight.
|
||||
const source = (r.type === 'train' ? getTrainLegs(r) : getFlightLegs(r))[l.index]
|
||||
subtitleLines = [[
|
||||
l.airline, l.flight_number, l.train_number,
|
||||
l.platform ? `Gl. ${l.platform}` : '',
|
||||
l.seat ? `Seat ${l.seat}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
source?.confirmation_number,
|
||||
].filter(Boolean).join(' · ')].filter(Boolean)
|
||||
}
|
||||
else if (r.type === 'flight') {
|
||||
const legs = getFlightLegs(r)
|
||||
if (legs.length > 1) {
|
||||
// Multi-leg: one line per leg so every flight number + segment route is shown.
|
||||
// Multi-leg: one line per leg so every flight number + segment route is
|
||||
// shown, with the segment's own booking code where it has one (#1943).
|
||||
// At the gate that is the number the airline asks for.
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.airline, l.flight_number,
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
l.confirmation_number]
|
||||
.filter(Boolean).join(' · '))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
@@ -296,10 +321,12 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
else if (r.type === 'train') {
|
||||
const legs = getTrainLegs(r)
|
||||
if (legs.length > 1) {
|
||||
// Multi-leg: one line per leg so every train number + segment route shows.
|
||||
// Multi-leg: one line per leg so every train number + segment route shows,
|
||||
// with the segment's own booking code where it has one (#1943).
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
l.confirmation_number]
|
||||
.filter(Boolean).join(' · '))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
@@ -313,9 +340,9 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const phase = getSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const displayTime = getDisplayTimeForDay(r, day.id)
|
||||
// Start and end, the way the day plan draws it (#1310). Without the
|
||||
// landing time a flight reads as an open-ended block and the reader
|
||||
// cannot tell how much of the day is left for anything else.
|
||||
|
||||
@@ -447,7 +447,7 @@ describe('PackingListPanel', () => {
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
|
||||
|
||||
// Click the Import button (Upload icon in the header)
|
||||
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
|
||||
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
|
||||
expect(importBtn).toBeTruthy();
|
||||
await user.click(importBtn!);
|
||||
|
||||
@@ -538,7 +538,7 @@ describe('PackingListPanel', () => {
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
|
||||
|
||||
// Open import modal
|
||||
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
|
||||
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
|
||||
await user.click(importBtn!);
|
||||
await screen.findByText('Import Packing List');
|
||||
|
||||
@@ -655,7 +655,7 @@ describe('PackingListPanel', () => {
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
|
||||
|
||||
// Open import modal
|
||||
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
|
||||
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
|
||||
await user.click(importBtn!);
|
||||
await screen.findByText('Import Packing List');
|
||||
|
||||
@@ -929,7 +929,7 @@ describe('PackingListPanel', () => {
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
|
||||
|
||||
// Open import modal
|
||||
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
|
||||
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
|
||||
await user.click(importBtn!);
|
||||
await screen.findByText('Import Packing List');
|
||||
|
||||
@@ -1215,7 +1215,7 @@ describe('PackingListPanel', () => {
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
|
||||
|
||||
// Open import modal
|
||||
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
|
||||
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
|
||||
await user.click(importBtn!);
|
||||
await screen.findByText('Import Packing List');
|
||||
|
||||
@@ -1500,7 +1500,7 @@ describe('PackingListPanel', () => {
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
|
||||
|
||||
// Open import modal
|
||||
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
|
||||
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
|
||||
await user.click(importBtn!);
|
||||
await screen.findByText('Import Packing List');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
X, Check, CheckCheck, Luggage, Package, FolderPlus, Upload,
|
||||
X, Check, CheckCheck, Luggage, Package, FolderPlus, Download,
|
||||
} from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
@@ -46,7 +46,7 @@ export function PackingHeader(S: PackingState) {
|
||||
border: '1px solid var(--border-primary)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer',
|
||||
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
<Download size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
)}
|
||||
{inlineHeader && canEdit && abgehakt > 0 && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { FileDown } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
export function BulkImportModal(S: PackingState) {
|
||||
@@ -48,7 +48,7 @@ export function BulkImportModal(S: PackingState) {
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Upload size={11} /> {t('packing.importCsv')}
|
||||
<FileDown size={11} /> {t('packing.importCsv')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
buildUser, buildTrip, buildDay, buildPlace, buildCategory, buildAssignment, buildDayNote, buildReservation,
|
||||
} from '../../../tests/helpers/factories'
|
||||
import type { Accommodation, Reservation } from '../../types'
|
||||
import { calculateRouteWithLegs, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||
import { calculateRouteWithLegs, generateCoMapsUrl, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||
import DayPlanSidebar from './DayPlanSidebar'
|
||||
import { makeMarkerDraggable } from '../Map/markerDrag'
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(un
|
||||
vi.mock('../Map/RouteCalculator', () => ({
|
||||
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) => Promise.resolve({
|
||||
@@ -196,6 +197,7 @@ beforeEach(() => {
|
||||
}),
|
||||
}))
|
||||
vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...')
|
||||
vi.mocked(generateCoMapsUrl).mockReturnValue('https://comaps.at/...')
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
// Cost totals fetch FX rates for their base currency; keep the suite hermetic.
|
||||
@@ -610,16 +612,17 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── PDF export ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-025: PDF export button is present', () => {
|
||||
it('FE-PLANNER-DAYPLAN-025: the export button is present', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-026: clicking PDF button calls downloadTripPDF', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-026: the PDF row in the export dialog calls downloadTripPDF', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('PDF'))
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('PDF'))
|
||||
await waitFor(() => {
|
||||
expect(downloadTripPDF).toHaveBeenCalled()
|
||||
})
|
||||
@@ -827,7 +830,8 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places, assignments: assigns, selectedDayId: 10, onReorder,
|
||||
})} />)
|
||||
// Find the Optimize button (contains 'optimize' text)
|
||||
// Found by its accessible name — the button is icon-only since #1981, so
|
||||
// the label lives on aria-label rather than in the button's text.
|
||||
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
|
||||
await user.click(optimizeBtn)
|
||||
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
|
||||
@@ -900,11 +904,14 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
|
||||
|
||||
// ── ICS export button ────────────────────────────────────────────────────
|
||||
// ── ICS export ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-043: ICS export button is present', () => {
|
||||
it('FE-PLANNER-DAYPLAN-043: the calendar export sits in the export dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
expect(screen.getByText('ICS')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
expect(await screen.findByText('Calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── getMergedItems: transport merged with assignments ──────────────────
|
||||
@@ -1120,13 +1127,12 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── PDF hover tooltip ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-054: hovering PDF button shows tooltip', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-054: hovering the export button shows its tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const pdfBtn = screen.getByText('PDF').closest('button')!
|
||||
await user.hover(pdfBtn)
|
||||
await user.hover(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => {
|
||||
// Tooltip text appears (from t('dayplan.pdfTooltip'))
|
||||
// Tooltip text appears (from t('dayplan.exportIntro'))
|
||||
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
|
||||
expect(tooltips.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -1194,10 +1200,9 @@ describe('DayPlanSidebar', () => {
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
// The ICS button now opens a hover menu (Download / Subscribe) instead of
|
||||
// downloading on direct click.
|
||||
await user.hover(screen.getByText('ICS').closest('button')!)
|
||||
await user.click(await screen.findByText('Download ICS'))
|
||||
// The three export buttons collapsed into one that opens the export dialog.
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('Download .ics'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
fetchSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
@@ -1831,8 +1836,8 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] },
|
||||
})} />)
|
||||
const pdfBtn = screen.getByRole('button', { name: /pdf/i })
|
||||
await user.click(pdfBtn)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('PDF'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dayNotes: expect.arrayContaining([expect.objectContaining({ text: 'PDF Note' })]) })
|
||||
))
|
||||
@@ -1916,16 +1921,17 @@ describe('DayPlanSidebar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── ICS hover tooltip ─────────────────────────────────────────────────────
|
||||
// ── Export dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-090: the export button opens the dialog with every format', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const icsBtn = screen.getByText('ICS').closest('button')!
|
||||
await user.hover(icsBtn)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Whole trip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2281,6 +2287,41 @@ describe('DayPlanSidebar', () => {
|
||||
expect(await screen.findByText('2 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-203: leg distance survives a parking on its middle days (#1937)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
|
||||
vi.mocked(calculateRouteWithLegs as any).mockImplementation((wp: any) => Promise.resolve({
|
||||
distanceText: '2 km', durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (wp?.length ?? 0) - 1) }, () => ({
|
||||
distanceText: '2 km', durationText: '10 min', drivingText: '10 min', walkingText: '25 min',
|
||||
})),
|
||||
}))
|
||||
// Same trap as the car rental in -106: the parking row is hidden on day 11, so the
|
||||
// through-leg between the places around it must stay keyed to the place.
|
||||
const parking = {
|
||||
...buildReservation({ id: 323, type: 'parking', title: 'Airport Parking', day_id: 10 }),
|
||||
end_day_id: 12,
|
||||
day_positions: { 11: 0.5 },
|
||||
}
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
|
||||
]
|
||||
const assigns = {
|
||||
'11': [
|
||||
buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }),
|
||||
buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days, places: [], assignments: assigns, reservations: [parking as any],
|
||||
selectedDayId: null, showRouteToolsWhenExpanded: true,
|
||||
})} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Route' }))
|
||||
expect(await screen.findByText('2 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Persisted / externally driven state ──────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-110: the saved collapse state wins over expanding every day', () => {
|
||||
@@ -2438,6 +2479,56 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getAllByText('Ghost rental')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-200: a multi-day parking shows on the first and last day only (#1937)', () => {
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
|
||||
]
|
||||
const parking = buildReservation({
|
||||
id: 320, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
|
||||
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
|
||||
// Drop-off on day 1, pickup on day 3, nothing in between.
|
||||
expect(screen.getAllByText('Airport Parking')).toHaveLength(2)
|
||||
// And no smaller tag in the day header either, unlike a rental car.
|
||||
expect(within(dayHeader('Day 2')).queryByText('Airport Parking')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-201: a multi-day parking labels its drop-off and its pickup day', () => {
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
|
||||
]
|
||||
const parking = buildReservation({
|
||||
id: 321, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
|
||||
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
|
||||
expect(screen.getByText('Drop-off')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pickup')).toBeInTheDocument()
|
||||
// The generic span wording never appears for a parking.
|
||||
expect(screen.queryByText('Ongoing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-202: a day left empty by a spanning parking shows the empty-day hint', () => {
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
|
||||
]
|
||||
const parking = buildReservation({
|
||||
id: 322, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
|
||||
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
|
||||
// Days 1 and 3 carry the booking, so only day 2 is empty, and it says so instead
|
||||
// of leaving a blank gap where the hidden row used to be.
|
||||
expect(screen.getAllByText('No places planned for this day')).toHaveLength(1)
|
||||
})
|
||||
|
||||
// ── Route legs, hotel bookends, travel modes ─────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-120: a routed day draws the hotel bookends around its stops', async () => {
|
||||
@@ -3327,7 +3418,7 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
|
||||
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
|
||||
await user.click(screen.getByRole('button', { name: 'Open Website' }))
|
||||
expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank')
|
||||
expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank', 'noopener,noreferrer')
|
||||
|
||||
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
|
||||
await user.click(screen.getByRole('button', { name: 'Google Maps' }))
|
||||
@@ -3360,15 +3451,48 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open in Google Maps' }))
|
||||
expect(vi.mocked(generateGoogleMapsUrl)).toHaveBeenCalledWith([
|
||||
{ lat: 48.85, lng: 2.35 },
|
||||
{ lat: 48.86, lng: 2.34 },
|
||||
{ lat: 48.87, lng: 2.33 },
|
||||
{ lat: 48.85, lng: 2.35 },
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
|
||||
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
])
|
||||
expect(openSpy).toHaveBeenCalledWith('https://maps.google.com/...', '_blank', 'noopener,noreferrer')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-168b: the CoMaps export hands over the same stops in the day travel mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { generateCoMapsUrl } = await import('../Map/RouteCalculator')
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2', default_transport_mode: 'walking' }),
|
||||
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
|
||||
]
|
||||
const accommodations: Accommodation[] = [{
|
||||
id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12,
|
||||
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
|
||||
}]
|
||||
const assignments = {
|
||||
'11': [
|
||||
buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }),
|
||||
buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open in CoMaps' }))
|
||||
// Same bookended stop list as the Google export — one source, so the two
|
||||
// cannot drift — plus the day's own mode, which CoMaps can actually route in.
|
||||
expect(vi.mocked(generateCoMapsUrl)).toHaveBeenCalledWith([
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
|
||||
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
], 'walking')
|
||||
expect(openSpy).toHaveBeenCalledWith('https://comaps.at/...', '_blank', 'noopener,noreferrer')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { daysApi } = await import('../../api/client')
|
||||
@@ -4078,3 +4202,47 @@ describe('reordering the day plan with a finger (#1616)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The day's route-tools row (#1981).
|
||||
*
|
||||
* Every button in it hands the day somewhere: show the route, open it in Google
|
||||
* Maps, open it in CoMaps, reorder it. Two of those were already icon-only; the
|
||||
* other two carried labels on `flex: 1` with `padding: '6px 0'`, which is no
|
||||
* horizontal padding at all. That was survivable until CoMaps added a fifth
|
||||
* button to the row, at which point the optimize label sat hard against both
|
||||
* edges of its own button.
|
||||
*
|
||||
* It is icon-only now, which is also what keeps the row from crowding again the
|
||||
* next time something is added to it.
|
||||
*/
|
||||
describe('the day route-tools row', () => {
|
||||
const dayWithTwoStops = () => {
|
||||
const places = [
|
||||
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||
]
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
return {
|
||||
days: [day], places, selectedDayId: 10,
|
||||
assignments: {
|
||||
'10': [
|
||||
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps the optimize action reachable by name without printing it', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps(dayWithTwoStops())} />)
|
||||
const btn = screen.getByRole('button', { name: /optimize/i })
|
||||
// The assertion that pins the change: named, but no visible label.
|
||||
expect(btn).toBeInTheDocument()
|
||||
expect(btn.textContent?.trim()).toBe('')
|
||||
expect(btn.getAttribute('title')).toBeTruthy()
|
||||
})
|
||||
|
||||
/* The click itself is already pinned by FE-PLANNER-DAYPLAN-038 above, which
|
||||
finds the button the same way. */
|
||||
})
|
||||
|
||||
@@ -4,10 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
|
||||
import GoogleMapsIcon from '../shared/GoogleMapsIcon'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
@@ -27,7 +29,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
@@ -211,7 +213,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
// Transit rows fold their itinerary out inline (#1065).
|
||||
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
|
||||
@@ -362,6 +363,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (phase === 'single') return null
|
||||
if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||
if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||
// Parking reuses span.pickup for its end day on purpose: you drop the car off and
|
||||
// later pick it up again, so the rental car's wording fits without a second key.
|
||||
if (r.type === 'parking') return t(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`)
|
||||
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
|
||||
@@ -513,11 +517,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
cur = []
|
||||
curHasPlace = false
|
||||
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng, isPlace: false })
|
||||
} else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle')) {
|
||||
} else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle') && !hidesOnMiddleDay(r, dayId)) {
|
||||
// No location: ignore for routing, but attribute the through-leg to the
|
||||
// booking so its distance/duration shows under it (purely cosmetic).
|
||||
// Not for a car rental's middle days though — that row isn't rendered
|
||||
// in the timeline, so re-keying would drop the leg entirely (#1504).
|
||||
// Not for a car rental's middle days or a multi-day parking's though:
|
||||
// those rows aren't rendered in the timeline, so re-keying would drop
|
||||
// the leg entirely (#1504).
|
||||
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
|
||||
}
|
||||
}
|
||||
@@ -1034,8 +1039,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
setHoveredAssignmentId,
|
||||
dropTargetKey,
|
||||
@@ -1209,8 +1212,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
setHoveredAssignmentId,
|
||||
dropTargetKey,
|
||||
@@ -1405,8 +1406,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
icsHover={icsHover}
|
||||
setIcsHover={setIcsHover}
|
||||
expandedDays={expandedDays}
|
||||
setExpandedDays={setExpandedDays}
|
||||
onUndo={onUndo}
|
||||
@@ -1453,6 +1452,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
(transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng)
|
||||
)
|
||||
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer
|
||||
/**
|
||||
* The day's located stops in planned order, bookended by the accommodation
|
||||
* the same way the drawn map route is (routeBookends is null when "optimize
|
||||
* from accommodation" is off), so hotels aren't dropped from an exported
|
||||
* route (#1372) — but only when the leg is real: no hotel prepended before an
|
||||
* early check-in-day stop, none appended after a post-check-out stop (#1465).
|
||||
* Names ride along for the deep links that can label a pin with one.
|
||||
*/
|
||||
const dayExportStops = (): NamedWaypoint[] => {
|
||||
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
|
||||
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
|
||||
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
|
||||
const lastAssignment = dayStops[dayStops.length - 1]
|
||||
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
|
||||
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last)
|
||||
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
|
||||
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
|
||||
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
|
||||
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null
|
||||
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
|
||||
}
|
||||
// Is this day's inline route currently on? Mobile toggles it per day (its
|
||||
// own expandedRouteDayIds entry); desktop uses the global Route toggle on
|
||||
// the selected day (#1374).
|
||||
@@ -1461,6 +1482,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const merged = mergedItemsMap[day.id] || []
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
// A row hidden on a middle day still sits in `merged` (the route builder above
|
||||
// has to see it), so the empty-day hint counts what actually renders. Without
|
||||
// that, a day whose only entry is a spanning parking would show a blank gap.
|
||||
const visibleCount = merged.filter(i => !(i.type === 'transport' && hidesOnMiddleDay(i.data, day.id))).length
|
||||
const dayTint = dayTints[day.id]
|
||||
// Resolved once per day: the header owns a background that its hover
|
||||
// handlers reassign imperatively, so both the base and the hover value have
|
||||
@@ -1719,7 +1744,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
) : connector
|
||||
})()}
|
||||
{daySchedule.byPosition[day.id]?.start.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
{visibleCount === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
@@ -1856,7 +1881,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
ctxMenu.open(e, [
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
|
||||
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
|
||||
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
|
||||
{ divider: true },
|
||||
@@ -1955,33 +1980,38 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
// The status, the time and the flight/train number used to sit in one
|
||||
// pill strung together on a middle dot, which read as one run-on
|
||||
// sentence. They are separate chips now: same tint so they still
|
||||
// belong together, own outline so the eye can take them one at a time.
|
||||
const RI = RES_ICONS[res.type] || Ticket
|
||||
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
|
||||
const chip: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 6px', borderRadius: 5,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
const timeLabel = st || et
|
||||
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
|
||||
: ''
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
const carrierLabel = meta
|
||||
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
|
||||
: ''
|
||||
return (
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<div className={confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
// No wrapping: the time belongs to the status it qualifies, so the
|
||||
// chips stay on one line even when the row gets narrow.
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
|
||||
<div className={tint} style={chip}>
|
||||
<RI size={8} />
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{(() => {
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!st && !et) return null
|
||||
return (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
|
||||
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2100,6 +2130,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
// Car "active" (middle) days are shown in the day header, skip here
|
||||
if (res.type === 'car' && spanPhase === 'middle') return null
|
||||
// A multi-day parking says nothing on the days in between and gets
|
||||
// no header badge either, only drop-off and pickup (#1937)
|
||||
if (hidesOnMiddleDay(res, day.id)) return null
|
||||
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const color = '#3b82f6'
|
||||
@@ -2539,24 +2572,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
// Bookend the Google Maps route with the day's accommodation the
|
||||
// same way the drawn map route does (routeBookends is null when
|
||||
// "optimize from accommodation" is off), so hotels aren't dropped
|
||||
// from the exported route (#1372) — but only when the leg is real:
|
||||
// no hotel prepended before an early check-in-day stop, none appended
|
||||
// after a post-check-out stop (#1465).
|
||||
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
|
||||
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! }))
|
||||
const firstStop = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
|
||||
const lastAssignment = dayStops[dayStops.length - 1]
|
||||
const lastStop = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
|
||||
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, firstStop)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, lastStop)
|
||||
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
|
||||
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
|
||||
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
|
||||
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
|
||||
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
|
||||
const url = generateGoogleMapsUrl(dayExportStops())
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
@@ -2568,20 +2584,45 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
|
||||
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||
</svg>
|
||||
<GoogleMapsIcon size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<RotateCcw size={12} strokeWidth={2} />
|
||||
{t('dayplan.optimize')}
|
||||
{/* The same day, handed to CoMaps for offline navigation (#1904). The
|
||||
day's own travel mode rides along, so the route it builds walks
|
||||
when the plan walks. */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openCoMaps')}
|
||||
title={t('planner.openCoMaps')}
|
||||
className="bg-transparent text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Compass size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{/* Icon-only, like the two map hand-offs beside it (#1981). It
|
||||
was the one button here carrying a label with no room for
|
||||
it: `flex: 1` alongside `padding: '6px 0'` meant the text
|
||||
sat against both edges as soon as the row filled up, and
|
||||
the row gained a button when CoMaps arrived. The label
|
||||
stays as the accessible name and the tooltip. */}
|
||||
<button
|
||||
onClick={() => handleOptimize(day.id)}
|
||||
aria-label={t('dayplan.optimize')}
|
||||
title={t('dayplan.optimize')}
|
||||
className="bg-surface-hover text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{routeProfileOptions.map(p => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-022
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { buildDay, buildReservation, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
// The subscribe dialog fetches its feed token on mount; it is exercised in its
|
||||
// own test, here we only care that opening the menu entry mounts it.
|
||||
// own test, and TripExportModal covers the entry that opens it.
|
||||
vi.mock('./IcsSubscribeModal', () => ({
|
||||
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
|
||||
<div data-testid="ics-subscribe-modal">
|
||||
@@ -46,8 +46,6 @@ function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebar
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: makeToast(),
|
||||
icsHover: false,
|
||||
setIcsHover: vi.fn((_v: boolean) => {}),
|
||||
expandedDays: new Set<number>(),
|
||||
setExpandedDays: vi.fn((_v: Set<number>) => {}),
|
||||
canUndo: false,
|
||||
@@ -72,37 +70,42 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('DayPlanSidebarToolbar', () => {
|
||||
// ── PDF export ────────────────────────────────────────────────────────────
|
||||
// ── Export ─────────────────────────────────────────────────────
|
||||
// PDF, ICS and GPX each used to be their own button with its own hover menu.
|
||||
// The mechanics now live in TripExportModal and are tested there; what is left
|
||||
// here is the single button that opens it.
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-001: the PDF button exports the trip with the day notes flattened', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1' })]
|
||||
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, dayNotes })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
|
||||
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
it('FE-PLANNER-DPTOOLBAR-001: the toolbar carries one export button, not three', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.export' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('ICS')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('GPX')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-002: a failing PDF export surfaces the error via the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button shows the export tooltip', async () => {
|
||||
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument())
|
||||
await user.unhover(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument())
|
||||
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false')
|
||||
await user.click(btn)
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.exportDocument')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the export button shows its tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument()
|
||||
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
||||
await user.hover(btn)
|
||||
await waitFor(() => expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument())
|
||||
await user.unhover(btn)
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
// The button sits at the right edge of the leftmost pane. Its own tooltip was
|
||||
@@ -111,8 +114,8 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
const tip = await screen.findByText('dayplan.pdfTooltip')
|
||||
await user.hover(screen.getByRole('button', { name: 'dayplan.export' }))
|
||||
const tip = await screen.findByText('dayplan.exportIntro')
|
||||
expect(tip.closest('[role="tooltip"]')).not.toBeNull()
|
||||
expect(tip.parentElement).toBe(document.body)
|
||||
expect(tip.style.position).toBe('fixed')
|
||||
@@ -120,94 +123,12 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0))
|
||||
})
|
||||
|
||||
// ── ICS menu ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-005: hovering ICS opens the download/subscribe menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setIcsHover = vi.fn((_v: boolean) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
expect(setIcsHover).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-005b: without share_manage the menu offers the download but not the subscription', async () => {
|
||||
// The subscription mints a link that reads the trip without an account, so
|
||||
// it needs share_manage; the one-off download is a file this member may
|
||||
// already read. Leaving the entry visible would only produce a dialog whose
|
||||
// enable button the server refuses.
|
||||
it('FE-PLANNER-DPTOOLBAR-005: the dialog inherits the share permission from the toolbar', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ canManageShare: false })} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setIcsHover = vi.fn((_v: boolean) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
const icsButton = screen.getByText('ICS')
|
||||
await user.hover(icsButton)
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
await user.unhover(icsButton)
|
||||
await waitFor(() => expect(screen.queryByText('Download ICS')).not.toBeInTheDocument())
|
||||
expect(setIcsHover).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-007: re-entering the ICS area cancels the pending close', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
const icsButton = screen.getByText('ICS')
|
||||
await user.hover(icsButton)
|
||||
await user.unhover(icsButton)
|
||||
await user.hover(icsButton)
|
||||
// The close is scheduled 120ms out; coming back in has to cancel it.
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-008: "Download ICS" fetches the export and hands it to a download link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as unknown as Response)
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Download ICS'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
|
||||
await waitFor(() => expect(clickSpy).toHaveBeenCalled())
|
||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
||||
// The menu closes itself once the download starts.
|
||||
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
|
||||
fetchSpy.mockRestore(); createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-009: a rejected ICS export shows the failure toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as unknown as Response)
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Download ICS'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-010: "Subscribe to calendar" opens the subscribe dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Subscribe to calendar'))
|
||||
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('close-subscribe'))
|
||||
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.export' }))
|
||||
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Expand / collapse all ────────────────────────────────────────────────
|
||||
@@ -350,105 +271,6 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(routeBtn.style.background).toBe('var(--bg-hover)')
|
||||
await user.unhover(routeBtn)
|
||||
expect(routeBtn.style.background).toBe('transparent')
|
||||
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
const download = screen.getByText('Download ICS')
|
||||
const subscribe = screen.getByText('Subscribe to calendar')
|
||||
await user.hover(download)
|
||||
expect(download.style.background).toContain('var(--bg-hover')
|
||||
// Sliding down to the next entry has to clear the one we left.
|
||||
await user.hover(subscribe)
|
||||
expect(download.style.background).toBe('transparent')
|
||||
expect(subscribe.style.background).toContain('var(--bg-hover')
|
||||
await user.unhover(subscribe)
|
||||
expect(subscribe.style.background).toBe('transparent')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-024: an aborted PDF export without an Error still reaches the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
|
||||
// ── GPX export (#1442) ────────────────────────────────────────────────────
|
||||
describe('GPX menu', () => {
|
||||
let clickedHref: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
clickedHref = null
|
||||
// jsdom has neither of these, and an anchor click would navigate.
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:gpx')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clickedHref = this.href
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const okResponse = () => ({ ok: true, status: 200, blob: async () => new Blob(['<gpx/>']) }) as unknown as Response
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.gpxAll')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
expect(screen.getByText('dayplan.gpxAll')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxPlaces')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxDays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-026: each scope asks the server for exactly its own selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
|
||||
for (const [label, expected] of [
|
||||
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
|
||||
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
|
||||
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
|
||||
] as const) {
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText(label))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
|
||||
fetchMock.mockClear()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-027: a download names the file after the trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:gpx'))
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-028: an empty trip says so instead of reporting a failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(clickedHref).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-029: a real failure toasts the error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, MapPin, Route as RouteIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Download, Undo2, ArrowUpDown, Route as RouteIcon } from 'lucide-react'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
* rather than by the GPX element it maps to: "everything", "just the places" for
|
||||
* an offline map, "just the days" for following a plan. An omitted flag defaults
|
||||
* to true server-side, so the first entry needs no query at all.
|
||||
*/
|
||||
const GPX_SCOPES = [
|
||||
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: FileDown },
|
||||
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
|
||||
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
|
||||
] as const
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
@@ -33,8 +21,6 @@ interface DayPlanSidebarToolbarProps {
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
icsHover: boolean
|
||||
setIcsHover: (v: boolean) => void
|
||||
expandedDays: Set<number>
|
||||
setExpandedDays: (next: Set<number>) => void
|
||||
onUndo?: () => void
|
||||
@@ -44,11 +30,9 @@ interface DayPlanSidebarToolbarProps {
|
||||
lastActionLabel: string | null
|
||||
canEditDays?: boolean
|
||||
/**
|
||||
* Gates "Subscribe to calendar" only. The one-off "Download ICS" below it
|
||||
* stays open to every member: it is a file they already have the right to
|
||||
* read, while the subscription mints a link that works without an account.
|
||||
* Defaults to true so a caller that has not wired the permission through
|
||||
* keeps today's menu rather than silently losing an entry.
|
||||
* Gates "Subscribe to calendar" in the export dialog only. Defaults to true so
|
||||
* a caller that has not wired the permission through keeps today's entries
|
||||
* rather than silently losing one.
|
||||
*/
|
||||
canManageShare?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
@@ -58,230 +42,53 @@ interface DayPlanSidebarToolbarProps {
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast, setIcsHover,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
|
||||
const gpxMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [gpxMenuVisible, setGpxMenuVisible] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
|
||||
const showIcsMenu = () => {
|
||||
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
|
||||
setIcsMenuVisible(true)
|
||||
setIcsHover(true)
|
||||
}
|
||||
const hideIcsMenu = () => {
|
||||
icsMenuTimeoutRef.current = setTimeout(() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const showGpxMenu = () => {
|
||||
if (gpxMenuTimeoutRef.current) clearTimeout(gpxMenuTimeoutRef.current)
|
||||
setGpxMenuVisible(true)
|
||||
}
|
||||
const hideGpxMenu = () => {
|
||||
gpxMenuTimeoutRef.current = setTimeout(() => setGpxMenuVisible(false), 120)
|
||||
}
|
||||
|
||||
const downloadGpx = async (query: string) => {
|
||||
setGpxMenuVisible(false)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
|
||||
// 404 here means the selection is empty, which is worth its own message:
|
||||
// "nothing happened" and "the download broke" look identical otherwise.
|
||||
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.gpx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('dayplan.gpxFailed')) }
|
||||
}
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
width: '100%', padding: '7px 12px', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Tooltip label={t('dayplan.pdfTooltip')} placement="bottom">
|
||||
{/* One export button instead of three: PDF, ICS and GPX each carried
|
||||
their own hover menu, and on a narrower sidebar the row ran out of
|
||||
width and pushed them off the edge. The dialog holds every option. */}
|
||||
<Tooltip label={t('dayplan.exportIntro')} placement="bottom">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB and hangs off the
|
||||
// days sidebar, so every trip used to pay for it whether or not
|
||||
// anyone exported. A missing chunk lands in the catch below and
|
||||
// shows the same error the export already had.
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => setExportOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={exportOpen}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
<Download size={13} strokeWidth={2} />
|
||||
{t('dayplan.export')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
{icsMenuVisible && (
|
||||
<div
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 160,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<FileDown size={12} strokeWidth={2} />
|
||||
Download ICS
|
||||
</button>
|
||||
{canManageShare && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
setSubscribeOpen(true)
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<CalendarPlus size={12} strokeWidth={2} />
|
||||
Subscribe to calendar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* GPX — the counterpart to the GPX import in the places sidebar, sitting
|
||||
with the other exports rather than with the importer. Same hover-menu
|
||||
shape as ICS beside it. */}
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
>
|
||||
<button
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={gpxMenuVisible}
|
||||
aria-label={t('dayplan.gpxTooltip')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
GPX
|
||||
</button>
|
||||
{gpxMenuVisible && (
|
||||
<div
|
||||
role="menu"
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 190,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{GPX_SCOPES.map(scope => (
|
||||
<button
|
||||
key={scope.key}
|
||||
role="menuitem"
|
||||
onClick={() => downloadGpx(scope.query)}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<scope.icon size={12} strokeWidth={2} />
|
||||
{t(scope.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title="Subscribe to calendar"
|
||||
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<TripExportModal
|
||||
isOpen={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
dayNotes={dayNotes}
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
canManageShare={canManageShare}
|
||||
/>
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-018
|
||||
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-019
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -235,6 +235,28 @@ describe('DayPlanSidebarTransportDetailModal', () => {
|
||||
expect(setTransportDetail).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-019: a segment with its own booking code gets its own blurred field (#1943)', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } })
|
||||
const res = buildReservation({
|
||||
id: 42, type: 'flight', title: 'BER → CDG → JFK', status: 'confirmed',
|
||||
reservation_time: '2025-06-15T08:30:00',
|
||||
confirmation_number: 'XY7Z9Q',
|
||||
metadata: JSON.stringify({
|
||||
legs: [
|
||||
{ from: 'BER', to: 'CDG', confirmation_number: 'ABC123' },
|
||||
{ from: 'CDG', to: 'JFK' },
|
||||
],
|
||||
}),
|
||||
} as Partial<Reservation>)
|
||||
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
|
||||
|
||||
expect(screen.getByText('BER → CDG')).toBeInTheDocument()
|
||||
expect(screen.getByText('ABC123')).toHaveStyle({ filter: 'blur(5px)' })
|
||||
// The booking's own reference keeps its own field, a code-less segment adds none.
|
||||
expect(screen.getByText('XY7Z9Q')).toBeInTheDocument()
|
||||
expect(screen.queryByText('CDG → JFK')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTRANSPORT-018: clicking the backdrop closes, clicking the card does not', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setTransportDetail = vi.fn()
|
||||
|
||||
@@ -57,6 +57,16 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
}
|
||||
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
|
||||
// A stopover booking can carry its own reference per segment (#1943); the
|
||||
// flat fields above only ever describe the first leg. Marked sensitive so
|
||||
// the blur setting covers them like the booking's own code.
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
|
||||
for (const leg of meta.legs) {
|
||||
if (!leg?.confirmation_number) continue
|
||||
const segment = [leg.from, leg.to].filter(Boolean).join(' → ')
|
||||
detailFields.push({ label: segment || t('reservations.confirmationCode'), value: leg.confirmation_number, sensitive: true })
|
||||
}
|
||||
}
|
||||
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
|
||||
|
||||
return (
|
||||
@@ -90,7 +100,7 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
<div className={res.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{
|
||||
padding: '3px 8px', borderRadius: 6, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
}}>
|
||||
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
|
||||
{res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -307,7 +307,22 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// The collapsed row shows the CURRENT day in the place's timezone, so a test
|
||||
// that names a specific line has to say which day it is standing on. Without
|
||||
// this the suite passed Monday to Friday and failed every weekend, which reads
|
||||
// as a broken build rather than a test that forgot to pin its clock.
|
||||
// SELECTION is in Cologne, so midday UTC is safely the same date in Berlin.
|
||||
const pinDay = (iso: string) => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date(iso))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('FE-PDC-019: shows only today collapsed, not the whole week', async () => {
|
||||
pinDay('2026-08-12T12:00:00Z') // Wednesday
|
||||
withHours()
|
||||
renderColumn()
|
||||
|
||||
@@ -318,6 +333,19 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
expect(screen.queryByText('10:00-14:00')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-019b: the collapsed row follows the day, it is not just the first line', async () => {
|
||||
// The case that used to take the suite down every Saturday. Worth keeping as
|
||||
// a test rather than only pinning 019: it is the assertion that the day
|
||||
// lookup happens at all.
|
||||
pinDay('2026-08-15T12:00:00Z') // Saturday
|
||||
withHours()
|
||||
renderColumn()
|
||||
|
||||
await screen.findByText('inspector.openingHours')
|
||||
expect(screen.queryAllByText('10:00-14:00')).toHaveLength(1)
|
||||
expect(screen.queryByText('09:00-18:00')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-020: opens the full week on click', async () => {
|
||||
withHours()
|
||||
renderColumn()
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { PlacesSession } from '../../utils/placesSession'
|
||||
|
||||
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
||||
// category_id is normalised, plus any files chosen before the place existed.
|
||||
@@ -106,6 +107,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [acHighlight, setAcHighlight] = useState(-1)
|
||||
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const acAbortRef = useRef<AbortController | null>(null)
|
||||
// Ties one search's keystrokes and its details lookup into a single Google
|
||||
// billing session (see utils/placesSession).
|
||||
const placesSessionRef = useRef(new PlacesSession())
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
|
||||
@@ -244,7 +248,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const controller = new AbortController()
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal)
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcHighlight(-1)
|
||||
} catch (err: unknown) {
|
||||
@@ -263,6 +267,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) {
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
placesSessionRef.current.end()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -349,7 +354,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||
let place: Record<string, unknown> | null = null
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
// Spends the session the suggestions opened, so Google bills the search
|
||||
// once rather than per keystroke.
|
||||
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||
place = result.place
|
||||
}
|
||||
@@ -373,6 +380,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
placesSessionRef.current.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -425,6 +425,103 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030b: the linked reservation opens its editor (#2012)', () => {
|
||||
const onEditReservation = vi.fn();
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[reservation]}
|
||||
onEditReservation={onEditReservation}
|
||||
/>
|
||||
);
|
||||
const strip = screen.getByText('Museum Ticket').closest('[role="button"]') as HTMLElement;
|
||||
expect(strip).toBeTruthy();
|
||||
fireEvent.click(strip);
|
||||
expect(onEditReservation).toHaveBeenCalledWith(reservation);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030e: a transport booking goes to the transport editor (#2012)', () => {
|
||||
const onEditTransport = vi.fn();
|
||||
const onEditReservation = vi.fn();
|
||||
// A ferry is a transport, and ReservationModal has no transport type to hold it.
|
||||
const reservation = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[reservation]}
|
||||
onEditTransport={onEditTransport}
|
||||
onEditReservation={onEditReservation}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Ferry to Corfu').closest('[role="button"]') as HTMLElement);
|
||||
expect(onEditTransport).toHaveBeenCalledWith(reservation);
|
||||
expect(onEditReservation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030f: no transport handler means no affordance on a transport (#2012)', () => {
|
||||
// A member who may edit bookings but not days must not get a button that no-ops.
|
||||
const reservation = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[reservation]}
|
||||
onEditReservation={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Ferry to Corfu').closest('[role="button"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030c: Enter and Space open it too (#2012)', () => {
|
||||
const onEditReservation = vi.fn();
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[reservation]}
|
||||
onEditReservation={onEditReservation}
|
||||
/>
|
||||
);
|
||||
const strip = screen.getByText('Museum Ticket').closest('[role="button"]') as HTMLElement;
|
||||
expect(strip.getAttribute('tabindex')).toBe('0');
|
||||
fireEvent.keyDown(strip, { key: 'Enter' });
|
||||
fireEvent.keyDown(strip, { key: ' ' });
|
||||
expect(onEditReservation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030d: without a handler the strip stays a read-only summary', () => {
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[reservation]}
|
||||
/>
|
||||
);
|
||||
// A viewer with no edit right gets no target, and no misleading pointer.
|
||||
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toBeNull();
|
||||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Participants ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => {
|
||||
@@ -953,7 +1050,7 @@ describe('PlaceInspector', () => {
|
||||
|
||||
fireEvent.click(screen.getByText('Open Website').closest('button')!);
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']);
|
||||
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank', 'noopener,noreferrer']);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -29,6 +30,7 @@ import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/f
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
@@ -142,6 +144,11 @@ interface PlaceInspectorProps {
|
||||
selectedAssignmentId?: number | null
|
||||
assignments?: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
/** Editors for the linked booking, each omitted when the user lacks that right —
|
||||
* a transport needs day_edit, anything else reservation_edit. Both absent leaves
|
||||
* the strip the read-only summary it has always been (#2012). */
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
onEditReservation?: (reservation: Reservation) => void
|
||||
onClose: () => void
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
@@ -167,7 +174,7 @@ interface PlaceInspectorProps {
|
||||
|
||||
export default function PlaceInspector({
|
||||
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
|
||||
assignments = {}, reservations = [],
|
||||
assignments = {}, reservations = [], onEditTransport, onEditReservation,
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
@@ -318,6 +325,10 @@ export default function PlaceInspector({
|
||||
detailLng,
|
||||
googleDetails?.open_now,
|
||||
)
|
||||
// Allow-listed rather than passed straight through: window.open runs a
|
||||
// javascript: URL in this origin, and the stored value predates the check the
|
||||
// server does on the way in now.
|
||||
const websiteUrl = safeHttpUrl(place.website) ?? safeHttpUrl(googleDetails?.website)
|
||||
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
|
||||
const navigationTargets = getNavigationTargets(
|
||||
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
|
||||
@@ -421,7 +432,8 @@ export default function PlaceInspector({
|
||||
{mode === 'trip' && (
|
||||
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
|
||||
assignments={assignments} selectedDayId={selectedDayId} tripMembers={tripMembers} locale={locale}
|
||||
timeFormat={timeFormat} t={t} onSetParticipants={onSetParticipants} />
|
||||
timeFormat={timeFormat} t={t} onSetParticipants={onSetParticipants}
|
||||
onEditTransport={onEditTransport} onEditReservation={onEditReservation} />
|
||||
)}
|
||||
|
||||
{/* Opening hours + Files — side by side on desktop only if both exist */}
|
||||
@@ -513,8 +525,8 @@ export default function PlaceInspector({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(place.website || googleDetails?.website) && (
|
||||
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||
{websiteUrl && (
|
||||
<ActionButton onClick={() => window.open(websiteUrl, '_blank', 'noopener,noreferrer')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
@@ -809,7 +821,7 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
|
||||
}
|
||||
|
||||
function PlaceReservationParticipants({ selectedAssignmentId, reservations, assignments, selectedDayId,
|
||||
tripMembers, locale, timeFormat, t, onSetParticipants }: any) {
|
||||
tripMembers, locale, timeFormat, t, onSetParticipants, onEditTransport, onEditReservation }: any) {
|
||||
return (
|
||||
<>
|
||||
{(() => {
|
||||
@@ -825,8 +837,25 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
|
||||
{/* Reservation */}
|
||||
{res && (() => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
// The strip summarised the booking but went nowhere, so its
|
||||
// attachments and fields had no route from the map (#2012).
|
||||
// A transport has its own form; picking by type is what the day
|
||||
// sidebar does, and an absent handler means this user may not
|
||||
// open this one — so the strip stays inert rather than lying.
|
||||
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
|
||||
const open = editor ? () => editor(res) : undefined
|
||||
return (
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
<div
|
||||
role={open ? 'button' : undefined}
|
||||
aria-label={open ? t('inspector.editRes') : undefined}
|
||||
tabIndex={open ? 0 : undefined}
|
||||
onClick={open}
|
||||
onKeyDown={open ? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
|
||||
} : undefined}
|
||||
title={open ? t('inspector.editRes') : undefined}
|
||||
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
|
||||
>
|
||||
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
|
||||
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
|
||||
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search, Plus, X, Upload, ChevronDown, Check, MapPin, Star } from 'lucide-react'
|
||||
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
@@ -54,7 +54,7 @@ export function PlacesHeader(S: SidebarState) {
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setListImportOpen(true)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-072
|
||||
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-078
|
||||
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -66,15 +66,16 @@ describe('ReservationsPanel', () => {
|
||||
|
||||
it('FE-COMP-RES-005: shows Manual Booking add button', () => {
|
||||
render(<ReservationsPanel {...defaultProps} />);
|
||||
// Button text is reservations.addManual = "Manual Booking"
|
||||
expect(screen.getByText('Manual Booking')).toBeInTheDocument();
|
||||
// Button text is reservations.addManual = "Manual Booking" — in the toolbar
|
||||
// and, on an empty list, in the empty state's call to action too (#2007).
|
||||
expect(screen.getAllByText('Manual Booking').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-RES-006: clicking Manual Booking button calls onAdd', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdd = vi.fn();
|
||||
render(<ReservationsPanel {...defaultProps} onAdd={onAdd} />);
|
||||
await user.click(screen.getByText('Manual Booking'));
|
||||
await user.click(screen.getAllByText('Manual Booking')[0]);
|
||||
expect(onAdd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -239,6 +240,39 @@ describe('ReservationsPanel', () => {
|
||||
expect(codeEl.style.filter).toBe('none');
|
||||
});
|
||||
|
||||
const layoverFlight = () => buildReservation({
|
||||
id: 7, type: 'flight', status: 'confirmed', confirmation_number: 'BOOK1',
|
||||
metadata: JSON.stringify({
|
||||
departure_airport: 'FRA', arrival_airport: 'HND',
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
|
||||
{ from: 'BER', to: 'HND' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-077: a segment with its own booking code shows it under its route (#1943)', () => {
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
|
||||
// The booking's own reference keeps its own cell.
|
||||
expect(screen.getByText('BOOK1')).toBeInTheDocument();
|
||||
expect(screen.getByText('FRA → BER')).toBeInTheDocument();
|
||||
expect(screen.getByText('ABC123')).toBeInTheDocument();
|
||||
// A segment without its own code adds no cell.
|
||||
expect(screen.queryByText('BER → HND')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-078: a segment code obeys blur_booking_codes and reveals with the card', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
|
||||
const legCode = screen.getByText('ABC123');
|
||||
expect(legCode.style.filter).toContain('blur');
|
||||
// One reveal per card, not per segment: the booking's own cell uncovers too.
|
||||
await user.hover(legCode);
|
||||
expect(legCode.style.filter).toBe('none');
|
||||
expect(screen.getByText('BOOK1').style.filter).toBe('none');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-024: reservation notes are shown', () => {
|
||||
const res = buildReservation({ notes: 'Window seat requested', status: 'pending' });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
@@ -621,7 +655,7 @@ describe('ReservationsPanel', () => {
|
||||
|
||||
it('FE-PLANNER-RESP-058: the add button dims on hover and restores on leave', () => {
|
||||
render(<ReservationsPanel {...defaultProps} />);
|
||||
const add = screen.getByText('Manual Booking').closest('button') as HTMLButtonElement;
|
||||
const add = screen.getAllByText('Manual Booking')[0].closest('button') as HTMLButtonElement;
|
||||
act(() => { add.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(add.style.opacity).toBe('0.88');
|
||||
act(() => { add.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo, useEffect, type CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
@@ -22,7 +22,8 @@ import type { ViewContribution } from '../../api/client'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
|
||||
import PluginFrame from '../Plugins/PluginFrame'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
import { splitReservationDateTime, formatTime, cleanAmountText } from '../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
@@ -329,6 +330,41 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Per-segment booking codes (#1943). A stopover booking can carry its own
|
||||
reference per leg, and the cell above only ever shows the booking's own.
|
||||
The reveal is shared with that cell on purpose: uncovering the codes is
|
||||
one gesture per card, not one per segment. */}
|
||||
{(() => {
|
||||
if (r.type !== 'flight' && r.type !== 'train') return null
|
||||
const legs = r.type === 'flight' ? getFlightLegs(r) : getTrainLegs(r)
|
||||
if (legs.length < 2) return null
|
||||
const coded = legs.filter(l => l.confirmation_number)
|
||||
if (coded.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: coded.length > 1 ? `repeat(${Math.min(coded.length, 3)}, 1fr)` : '1fr' }}>
|
||||
{coded.map((l, i) => (
|
||||
<div key={i}>
|
||||
<div className={fieldLabelClass}>{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}</div>
|
||||
<div
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
className={`${fieldValueClass} text-center`}
|
||||
style={{
|
||||
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{l.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Type-specific metadata */}
|
||||
{(() => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
@@ -342,7 +378,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
|
||||
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${meta.price}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
|
||||
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${cleanAmountText(meta.price)}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (cells.length === 0) return null
|
||||
@@ -669,6 +705,14 @@ interface ReservationsPanelProps {
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
/** The empty state's call-to-action buttons — same shape as the toolbar's. */
|
||||
const CTA_STYLE: CSSProperties = {
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual', contributionView = 'reservations', tripMembers = [] }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
@@ -892,7 +936,32 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
{total === 0 && reservations.length === 0 ? (
|
||||
<EmptyState scene={contributionView === 'transports' ? 'transport' : 'bookings'} title={t('reservations.empty')} />
|
||||
<EmptyState
|
||||
scene={contributionView === 'transports' ? 'transport' : 'bookings'}
|
||||
title={t('reservations.empty')}
|
||||
// On a fresh trip the toolbar controls sit far right above an empty
|
||||
// page, so the import in particular went unnoticed (#2007).
|
||||
action={canEdit ? (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={CTA_STYLE}>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{t(addManualKey)}
|
||||
</button>
|
||||
{onImport && bookingImportAvailable && (
|
||||
<button onClick={onImport} className="bg-surface-card text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
|
||||
<Download size={14} strokeWidth={2} />
|
||||
{t('reservations.import.cta')}
|
||||
</button>
|
||||
)}
|
||||
{onAirTrailImport && airTrailAvailable && (
|
||||
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
|
||||
<Plane size={14} strokeWidth={2} />
|
||||
{t('reservations.airtrail.cta')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
) : total === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('places.noneFound')}</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-053
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-061
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -650,6 +650,105 @@ describe('TransportModal', () => {
|
||||
expect(payload.metadata.legs).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Per-segment booking references (#1943) ─────────────────────────────────
|
||||
|
||||
function multiLegFlight(): Reservation {
|
||||
const res = buildReservation({ id: 31, title: 'BRU → HEL → JFK', type: 'flight' });
|
||||
return Object.assign(res, {
|
||||
day_id: 10,
|
||||
end_day_id: 12,
|
||||
reservation_time: '2026-08-01T08:00',
|
||||
reservation_end_time: '2026-08-03T15:00',
|
||||
confirmation_number: 'BOOK1',
|
||||
metadata: {
|
||||
airline: 'Brussels Airlines', flight_number: 'SN 1234', departure_airport: 'BRU', arrival_airport: 'JFK',
|
||||
legs: [
|
||||
{ from: 'BRU', to: 'HEL', airline: 'Brussels Airlines', flight_number: 'SN 1234', confirmation_number: 'ABC123', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '12:30' },
|
||||
{ from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY 15', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '14:00', arr_day_id: 12, arr_time: '15:00' },
|
||||
],
|
||||
},
|
||||
endpoints: [
|
||||
{ id: 1, reservation_id: 31, role: 'from', sequence: 0, name: 'Brussels (BRU)', code: 'BRU', lat: 50.9, lng: 4.48, timezone: 'Europe/Brussels', local_date: '2026-08-01', local_time: '08:00' },
|
||||
{ id: 2, reservation_id: 31, role: 'stop', sequence: 1, name: 'Helsinki (HEL)', code: 'HEL', lat: 60.31, lng: 24.96, timezone: 'Europe/Helsinki', local_date: '2026-08-02', local_time: '14:00' },
|
||||
{ id: 3, reservation_id: 31, role: 'to', sequence: 2, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: '2026-08-03', local_time: '15:00' },
|
||||
],
|
||||
}) as unknown as Reservation;
|
||||
}
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-058: each segment of a stopover flight saves its own booking code', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → HEL → JFK');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const airports = screen.getAllByTestId('airport-select');
|
||||
fireEvent.change(airports[0], { target: { value: 'BRU' } });
|
||||
fireEvent.change(airports[1], { target: { value: 'HEL' } });
|
||||
fireEvent.change(airports[2], { target: { value: 'JFK' } });
|
||||
|
||||
// Two departing waypoints get a code field, the booking's own field stays last.
|
||||
const codes = screen.getAllByPlaceholderText('e.g. ABC12345');
|
||||
expect(codes).toHaveLength(3);
|
||||
fireEvent.change(codes[0], { target: { value: 'ABC123' } });
|
||||
fireEvent.change(codes[1], { target: { value: 'XYZ789' } });
|
||||
fireEvent.change(codes[2], { target: { value: 'BOOK1' } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
|
||||
// The booking's own reference stays where it was and is never mirrored.
|
||||
expect(payload.confirmation_number).toBe('BOOK1');
|
||||
expect(payload.metadata.confirmation_number).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-059: no per-segment field while the route writes no legs', async () => {
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
||||
|
||||
fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'BRU' } });
|
||||
fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } });
|
||||
// Only the booking's own field: a direct flight stores no legs.
|
||||
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
|
||||
|
||||
// A third waypoint whose airport is never picked drops out of the route, so
|
||||
// the legs are still not written and the field must stay away. Otherwise the
|
||||
// typed code would vanish on save.
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.getAllByTestId('airport-select')).toHaveLength(3);
|
||||
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-060: editing a stopover flight pre-fills every segment code and a re-save keeps them', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
|
||||
|
||||
const codes = screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
|
||||
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
|
||||
expect(payload.confirmation_number).toBe('BOOK1');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-061: dropping the stop drops the segment codes with the legs, never the booking code', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
|
||||
|
||||
// Removing the only stop collapses the route to a direct flight, so there are
|
||||
// no legs left to hold a per-segment code. Documented behaviour, not a bug:
|
||||
// the field disappears with them and the booking keeps its own reference.
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Delete$/i }));
|
||||
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.metadata.legs).toBeUndefined();
|
||||
expect(payload.confirmation_number).toBe('BOOK1');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-034: the departure-date picker moves the whole flight to another day', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
@@ -85,9 +85,12 @@ interface WaypointForm {
|
||||
airline: string
|
||||
flight_number: string
|
||||
seat: string
|
||||
// Booking reference of the leg leaving this waypoint: airlines often issue one
|
||||
// per flight rather than one per booking (#1943). Empty means the booking's own.
|
||||
confirmation_number: string
|
||||
}
|
||||
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
|
||||
}
|
||||
|
||||
// ── Multi-leg train stations ───────────────────────────────────────────────
|
||||
@@ -103,9 +106,10 @@ interface StationWaypointForm {
|
||||
train_number: string
|
||||
platform: string
|
||||
seat: string
|
||||
confirmation_number: string
|
||||
}
|
||||
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -264,6 +268,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||
// No fallback to src.confirmation_number: that one belongs to the
|
||||
// whole booking and stays in the form's own field, otherwise a
|
||||
// plain re-save would copy it onto the first leg.
|
||||
confirmation_number: legOut?.confirmation_number ?? '',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -305,6 +313,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
|
||||
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
|
||||
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||
// See the flight branch: the booking's own reference stays out of the legs.
|
||||
confirmation_number: legOut?.confirmation_number ?? '',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -399,6 +409,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
...(w.airline ? { airline: w.airline } : {}),
|
||||
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
||||
...(w.seat ? { seat: w.seat } : {}),
|
||||
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
@@ -426,6 +437,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
...(w.train_number ? { train_number: w.train_number } : {}),
|
||||
...(w.platform ? { platform: w.platform } : {}),
|
||||
...(w.seat ? { seat: w.seat } : {}),
|
||||
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
@@ -630,6 +642,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}),
|
||||
]
|
||||
|
||||
// The per-leg booking code is only offered where handleSubmit actually writes
|
||||
// metadata.legs: the SAME condition, over the waypoints that become endpoints,
|
||||
// not over the raw rows. Anywhere else the value would vanish on save (#1943).
|
||||
const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
|
||||
const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -792,7 +810,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className={`grid grid-cols-1 ${writesFlightLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
||||
@@ -805,6 +823,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
|
||||
</div>
|
||||
{writesFlightLegs && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -866,7 +891,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className={`grid grid-cols-1 ${writesTrainLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
|
||||
@@ -879,6 +904,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
|
||||
</div>
|
||||
{writesTrainLegs && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-014
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { buildDay, buildDayNote, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
// The subscribe dialog fetches its feed token on mount; it is exercised in its
|
||||
// own test, here we only care that the entry mounts it.
|
||||
vi.mock('./IcsSubscribeModal', () => ({
|
||||
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
|
||||
<div data-testid="ics-subscribe-modal">
|
||||
{title}
|
||||
<button onClick={onClose}>close-subscribe</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}|${Object.values(params).join('|')}` : key
|
||||
|
||||
const trip = buildTrip({ id: 1, title: 'Roadtrip' })
|
||||
|
||||
function makeToast() {
|
||||
return {
|
||||
success: vi.fn((_m: string) => {}),
|
||||
error: vi.fn((_m: string) => {}),
|
||||
warning: vi.fn((_m: string) => {}),
|
||||
info: vi.fn((_m: string) => {}),
|
||||
}
|
||||
}
|
||||
|
||||
function makeProps(overrides: Partial<React.ComponentProps<typeof TripExportModal>> = {}) {
|
||||
return {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
tripId: 1,
|
||||
trip,
|
||||
days: [],
|
||||
places: [],
|
||||
categories: [],
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
dayNotes: {},
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: makeToast(),
|
||||
...overrides,
|
||||
} as React.ComponentProps<typeof TripExportModal>
|
||||
}
|
||||
|
||||
let clickedHref: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clickedHref = null
|
||||
// jsdom has neither of these, and an anchor click would navigate.
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clickedHref = this.href
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const okResponse = (body = '<gpx/>') =>
|
||||
({ ok: true, status: 200, blob: async () => new Blob([body]) }) as unknown as Response
|
||||
|
||||
describe('TripExportModal', () => {
|
||||
it('FE-PLANNER-EXPORTMODAL-001: closed, it renders nothing', () => {
|
||||
render(<TripExportModal {...makeProps({ isOpen: false })} />)
|
||||
expect(screen.queryByText('dayplan.export')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-002: open, every export sits in its own section', () => {
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportMaps · GPX')).toBeInTheDocument()
|
||||
for (const label of ['dayplan.pdf', 'mobileTrip.icsDownload', 'mobileTrip.icsSubscribe',
|
||||
'dayplan.gpxAll', 'dayplan.gpxPlaces', 'dayplan.gpxDays']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
// ── PDF ───────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-003: the PDF row exports the trip with the day notes flattened', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1' })]
|
||||
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
|
||||
render(<TripExportModal {...makeProps({ days, dayNotes })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
|
||||
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-004: a finished PDF export closes the dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<TripExportModal {...makeProps({ onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-005: a failing PDF export toasts the error and keeps the dialog open', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
|
||||
const toast = makeToast()
|
||||
const onClose = vi.fn()
|
||||
render(<TripExportModal {...makeProps({ toast, onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-006: an aborted PDF export without an Error still reaches the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
|
||||
const toast = makeToast()
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-007: the ICS row fetches the export and hands it to a download link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse('BEGIN:VCALENDAR'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsDownload'))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-008: a rejected ICS export shows the failure toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
const toast = makeToast()
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsDownload'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
|
||||
expect(clickedHref).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-009: the subscribe row opens the subscribe dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
|
||||
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('close-subscribe'))
|
||||
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The subscription mints a link that reads the trip without an account, so it
|
||||
// needs share_manage; the one-off download is a file this member may already
|
||||
// read. Leaving the entry visible would only produce a dialog whose enable
|
||||
// button the server refuses.
|
||||
it('FE-PLANNER-EXPORTMODAL-010: without share_manage the download stays and the subscription goes', () => {
|
||||
render(<TripExportModal {...makeProps({ canManageShare: false })} />)
|
||||
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── GPX (#1442) ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-011: each scope asks the server for exactly its own selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
for (const [label, expected] of [
|
||||
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
|
||||
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
|
||||
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
|
||||
] as const) {
|
||||
const view = render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText(label))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
|
||||
fetchMock.mockClear()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-012: a download names the file after the trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
|
||||
await waitFor(() => expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-013: an empty trip says so instead of reporting a failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
const onClose = vi.fn()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
|
||||
render(<TripExportModal {...makeProps({ toast, onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(clickedHref).toBeNull()
|
||||
// Nothing was downloaded, so the dialog stays put.
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-014: a real failure toasts the error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
* rather than by the GPX element it maps to: "everything", "just the places" for
|
||||
* an offline map, "just the days" for following a plan. An omitted flag defaults
|
||||
* to true server-side, so the first entry needs no query at all.
|
||||
*/
|
||||
const GPX_SCOPES = [
|
||||
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: Download },
|
||||
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
|
||||
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
|
||||
] as const
|
||||
|
||||
interface TripExportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
dayNotes: Record<string, DayNote[]>
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
/**
|
||||
* Gates "Subscribe to calendar" only. The one-off ICS download above it stays
|
||||
* open to every member: it is a file they already have the right to read,
|
||||
* while the subscription mints a link that works without an account.
|
||||
*/
|
||||
canManageShare?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a trip leaves TREK, in one dialog: the day plan as a PDF, the
|
||||
* bookings as a calendar (a one-off .ics or a live subscription) and the map
|
||||
* data as GPX in the three scopes a device actually wants.
|
||||
*
|
||||
* It replaces three separate toolbar buttons, two of which opened hover menus of
|
||||
* their own. On a narrow sidebar that row simply ran out of width and the
|
||||
* exports dropped off the edge; one button cannot.
|
||||
*/
|
||||
export function TripExportModal({
|
||||
isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, canManageShare = true,
|
||||
}: TripExportModalProps) {
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
// Which row is working, so the dialog can say so instead of looking inert
|
||||
// while a 226 kB PDF builder is fetched and a document is rendered.
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const fileBase = trip?.title || 'trip'
|
||||
|
||||
// Shared tail of every download: Firefox and Safari cancel the download when
|
||||
// the object URL is revoked before they picked the blob up, hence the delay.
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
|
||||
}
|
||||
|
||||
const exportPdf = async () => {
|
||||
if (busy) return
|
||||
setBusy('pdf')
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) })),
|
||||
)
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB and hangs off the days
|
||||
// sidebar, so every trip used to pay for it whether or not anyone
|
||||
// exported. A missing chunk lands in the catch and shows the same error
|
||||
// the export already had.
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadIcs = async () => {
|
||||
if (busy) return
|
||||
setBusy('ics')
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
saveBlob(await res.blob(), `${fileBase}.ics`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('planner.icsExportFailed'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadGpx = async (key: string, query: string) => {
|
||||
if (busy) return
|
||||
setBusy(`gpx:${key}`)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
|
||||
// 404 here means the selection is empty, which is worth its own message:
|
||||
// "nothing happened" and "the download broke" look identical otherwise.
|
||||
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
|
||||
if (!res.ok) throw new Error()
|
||||
saveBlob(await res.blob(), `${fileBase}.gpx`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('dayplan.gpxFailed'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-accent-subtle text-accent-on">
|
||||
<Download size={16} strokeWidth={2} />
|
||||
</span>
|
||||
{t('dayplan.export')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p className="-mt-1 mb-5 text-caption text-content-muted">{t('dayplan.exportIntro')}</p>
|
||||
|
||||
<Section label={t('dayplan.exportDocument')}>
|
||||
<ExportRow
|
||||
icon={FileText}
|
||||
title={t('dayplan.pdf')}
|
||||
sub={t('dayplan.pdfTooltip')}
|
||||
busy={busy === 'pdf'}
|
||||
disabled={busy != null}
|
||||
onClick={exportPdf}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section label={t('dayplan.exportCalendar')}>
|
||||
<ExportRow
|
||||
icon={CalendarDays}
|
||||
title={t('mobileTrip.icsDownload')}
|
||||
sub={`${fileBase}.ics`}
|
||||
busy={busy === 'ics'}
|
||||
disabled={busy != null}
|
||||
onClick={downloadIcs}
|
||||
/>
|
||||
{canManageShare && (
|
||||
<ExportRow
|
||||
icon={CalendarPlus}
|
||||
title={t('mobileTrip.icsSubscribe')}
|
||||
sub={t('mobileTrip.icsSubscribeSub')}
|
||||
disabled={busy != null}
|
||||
onClick={() => setSubscribeOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* GPX — the counterpart to the GPX import in the places sidebar. The
|
||||
format name carries more than a translated heading would, so it is
|
||||
spelled out beside the plain-language one. */}
|
||||
<Section label={`${t('dayplan.exportMaps')} · GPX`}>
|
||||
{GPX_SCOPES.map(scope => (
|
||||
<ExportRow
|
||||
key={scope.key}
|
||||
icon={scope.icon}
|
||||
title={t(scope.labelKey)}
|
||||
busy={busy === `gpx:${scope.key}`}
|
||||
disabled={busy != null}
|
||||
onClick={() => downloadGpx(scope.key, scope.query)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</Modal>
|
||||
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title={t('mobileTrip.icsSubscribe')}
|
||||
description={t('mobileTrip.icsSubscribeSub')}
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1.5 px-1 text-caption font-bold uppercase tracking-[0.07em] text-content-faint">{label}</h3>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-xl border border-edge-faint bg-surface">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExportRow({ icon: Icon, title, sub, busy = false, disabled = false, onClick }: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
sub?: string
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="group flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] bg-surface-tertiary text-content-secondary transition-colors group-enabled:group-hover:bg-accent-subtle group-enabled:group-hover:text-accent-on">
|
||||
{busy ? <Loader2 size={16} strokeWidth={2} className="animate-spin" /> : <Icon size={16} strokeWidth={1.9} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body font-semibold text-content">{title}</span>
|
||||
{sub && <span className="block truncate text-caption text-content-muted">{sub}</span>}
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className="flex-none text-content-faint transition-transform group-enabled:group-hover:translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCoMapsUrlForPlace } from './placeCoMaps'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// FE-PLANNER-COMAPS-001 to FE-PLANNER-COMAPS-004
|
||||
|
||||
function place(overrides: Partial<Place> = {}): Place {
|
||||
return { name: 'Stephansdom', lat: 48.2038, lng: 16.3616, ...overrides } as unknown as Place
|
||||
}
|
||||
|
||||
describe('getCoMapsUrlForPlace', () => {
|
||||
it('FE-PLANNER-COMAPS-001: drops a named pin at the place', () => {
|
||||
expect(getCoMapsUrlForPlace(place()))
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616&n=Stephansdom')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-COMAPS-002: links over https, so an install-less device lands somewhere', () => {
|
||||
// The cm:// scheme opens the app but dead-ends in a browser without it;
|
||||
// comaps.at is both the app link and the install page.
|
||||
expect(getCoMapsUrlForPlace(place())).toMatch(/^https:\/\/comaps\.at\//)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-COMAPS-003: a nameless place still gets its pin', () => {
|
||||
expect(getCoMapsUrlForPlace(place({ name: '' })))
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-COMAPS-004: without coordinates there is nothing to pin', () => {
|
||||
expect(getCoMapsUrlForPlace(place({ lat: null, lng: null }))).toBeNull()
|
||||
expect(getCoMapsUrlForPlace(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AssignmentPlace, Place } from '../../types'
|
||||
|
||||
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
|
||||
|
||||
/**
|
||||
* Open a place in CoMaps, the privacy-focused offline OpenStreetMap navigator.
|
||||
* Requested in discussion #1904.
|
||||
*
|
||||
* The https form rather than the `cm://` scheme CoMaps also answers to: both open
|
||||
* the app when it is installed, but only this one has somewhere to land when it is
|
||||
* not — comaps.at, which is the install page. A PWA cannot ask whether an Android
|
||||
* app exists, so the fallback has to be the link itself.
|
||||
*
|
||||
* Coordinates are required: CoMaps places the pin from `ll` and uses `n` only to
|
||||
* label it, so a name on its own has nothing to attach to.
|
||||
*/
|
||||
export function getCoMapsUrlForPlace(place: PlaceLike | null | undefined): string | null {
|
||||
if (!place || place.lat == null || place.lng == null) return null
|
||||
const name = place.name?.trim()
|
||||
const n = name ? `&n=${encodeURIComponent(name)}` : ''
|
||||
return `https://comaps.at/map?v=1&ll=${place.lat},${place.lng}${n}`
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
|
||||
import type { Place } from '../../types'
|
||||
@@ -31,7 +30,7 @@ afterEach(() => { vi.restoreAllMocks() })
|
||||
describe('getNavigationTargets', () => {
|
||||
it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => {
|
||||
const targets = getNavigationTargets(place())
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
|
||||
expect(targets[0].label).toBe('Google Maps')
|
||||
})
|
||||
|
||||
@@ -59,7 +58,7 @@ describe('getNavigationTargets', () => {
|
||||
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
|
||||
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
|
||||
try {
|
||||
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm'])
|
||||
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm', 'comaps'])
|
||||
} finally { restore() }
|
||||
})
|
||||
|
||||
@@ -67,7 +66,7 @@ describe('getNavigationTargets', () => {
|
||||
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
|
||||
try {
|
||||
const targets = getNavigationTargets(place())
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
|
||||
// q next to ll labels the pin rather than searching blindly.
|
||||
expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616')
|
||||
} finally { restore() }
|
||||
@@ -88,6 +87,13 @@ describe('getNavigationTargets', () => {
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'osm'])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-007b: CoMaps needs the position, so a place without one loses it', () => {
|
||||
// CoMaps drops a pin from `ll` and only labels it with `n` — a name alone
|
||||
// has nothing to attach to, the same reason Waze and Apple Maps drop out.
|
||||
const targets = getNavigationTargets(place({ lat: null, lng: null, name: 'Stephansdom' }))
|
||||
expect(targets.map(t => t.id)).not.toContain('comaps')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
|
||||
expect(getNavigationTargets(null)).toEqual([])
|
||||
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
|
||||
@@ -95,7 +101,7 @@ describe('getNavigationTargets', () => {
|
||||
|
||||
it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => {
|
||||
const targets = getNavigationTargets(place({ name: '' }))
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
|
||||
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
|
||||
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { AssignmentPlace, Place } from '../../types'
|
||||
import { getCoMapsUrlForPlace } from './placeCoMaps'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
|
||||
|
||||
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||
|
||||
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm'
|
||||
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps'
|
||||
|
||||
export interface NavigationTarget {
|
||||
id: NavigationAppId
|
||||
@@ -86,6 +87,11 @@ export function getNavigationTargets(
|
||||
const osmUrl = getOpenStreetMapUrlForPlace(place)
|
||||
if (osmUrl) targets.push({ id: 'osm', label: 'OpenStreetMap', url: osmUrl })
|
||||
|
||||
// Last, beside the OSM entry it shares a map source with: CoMaps is the offline
|
||||
// end of this list, the one that still works with no signal.
|
||||
const coMapsUrl = getCoMapsUrlForPlace(place)
|
||||
if (coMapsUrl) targets.push({ id: 'comaps', label: 'CoMaps', url: coMapsUrl })
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
|
||||
@@ -643,7 +643,7 @@ describe('usePlacesSidebar context menu', () => {
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId('row-3'));
|
||||
await user.click(screen.getByRole('button', { name: 'Open Website' }));
|
||||
expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank');
|
||||
expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank', 'noopener,noreferrer');
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId('row-3'));
|
||||
await user.click(screen.getByRole('button', { name: 'Google Maps' }));
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
|
||||
export interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -276,7 +277,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
|
||||
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
|
||||
googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
||||
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
|
||||
{ divider: true },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookOpen, Bug, Coffee, ExternalLink, Heart, Info, Lightbulb } from 'luc
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import Section from './Section';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
interface Props {
|
||||
appVersion: string;
|
||||
@@ -9,6 +10,7 @@ interface Props {
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t, locale } = useTranslation();
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
@@ -22,7 +24,11 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
className="text-content-secondary"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}
|
||||
>
|
||||
{t('settings.about.description')}
|
||||
{/* The stock line calls TREK self-hosted and points at 'your own server'.
|
||||
Both are true for the reader who set it up and neither is for a customer
|
||||
of a hosted instance, so the mode picks the sentence rather than the
|
||||
wording being watered down for everybody. */}
|
||||
{t(managed ? 'settings.about.descriptionManaged' : 'settings.about.description')}
|
||||
</p>
|
||||
<p
|
||||
className="text-content-faint"
|
||||
@@ -52,6 +58,14 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Ko-fi, Buy Me a Coffee, Discord, and the issue/discussion links assume
|
||||
the reader runs this install and can act on it. On a centrally
|
||||
administered one they support somebody they are not the customer of,
|
||||
and file bugs against an instance they do not operate. The version and
|
||||
the source link below stay in both modes: AGPL §13 wants the source
|
||||
offered prominently to the people using it over a network, and that is
|
||||
not the part being trimmed here. */}
|
||||
{!managed && (<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
@@ -263,6 +277,26 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* What replaces the grids above. AGPL §13 asks for the source to be
|
||||
offered prominently to whoever uses the software over a network, and a
|
||||
customer of a hosted instance is exactly that reader. The support and
|
||||
bug-report links go; this does not. */}
|
||||
{managed && (
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.sourceTitle')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.sourceHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import LlmConnectionSection from './LlmConnectionSection'
|
||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
interface OAuthPreset {
|
||||
id: string
|
||||
@@ -96,11 +97,18 @@ interface McpToken {
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const S = useIntegrations()
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
||||
{S.llmEnabled && <LlmConnectionSection />}
|
||||
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
|
||||
runs themselves. On a managed install there is none, and every one of
|
||||
these would ask for an address that cannot be reached from here. */}
|
||||
{!managed && <PhotoProvidersSection />}
|
||||
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
|
||||
{/* Which model reads a booking, and what that costs, comes with the instance on
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
{S.llmEnabled && !managed && <LlmConnectionSection />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||
<McpTokenModals {...S} />
|
||||
<OAuthClientModals {...S} />
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('LlmConnectionSection', () => {
|
||||
// The endpoint is instance configuration since #1772, so it has no field here.
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
|
||||
screen.getByText('A local (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
normalizeStyleForProvider,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
@@ -161,6 +162,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [provider, setProvider] = useState<Provider>(initialProvider)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||
@@ -322,7 +324,9 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
{/* GL settings */}
|
||||
{provider !== 'leaflet' && (
|
||||
<div className="space-y-3">
|
||||
{provider === 'mapbox-gl' && (
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{provider === 'mapbox-gl' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { BookDocument } from '@trek/shared'
|
||||
import { SpreadView } from './SpreadView'
|
||||
import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode } from './bookSheets'
|
||||
|
||||
/**
|
||||
* The book laid out for a press.
|
||||
*
|
||||
* ── One renderer ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* These are the same `SpreadView`s the editor draws, at 1:1 with `print` on.
|
||||
* That is the whole reason Studio renders in DOM and positions in millimetres:
|
||||
* a second renderer for output is a second renderer to proofread against, and
|
||||
* the day it drifts is the day someone finds out by opening a printed book.
|
||||
*
|
||||
* ── What is added on top ─────────────────────────────────────────────────
|
||||
*
|
||||
* Only what a press needs and a screen does not: the bleed carried out past the
|
||||
* trim on all four sides, and crop marks outside it saying where to cut.
|
||||
* Nothing here changes where anything sits on the page.
|
||||
*/
|
||||
export function BookSheetsView({
|
||||
doc, mode, marks,
|
||||
}: {
|
||||
doc: BookDocument
|
||||
mode: SheetMode
|
||||
/** Crop marks, and the room around the bleed they need. */
|
||||
marks: boolean
|
||||
}) {
|
||||
const sheets = sheetsFor(doc, mode)
|
||||
|
||||
return (
|
||||
<div className="bx-book">
|
||||
{sheets.map((sheet, i) => (
|
||||
<SheetView
|
||||
key={`${sheet.spread.id}-${sheet.offset}`}
|
||||
sheet={sheet}
|
||||
doc={doc}
|
||||
marks={marks}
|
||||
last={i === sheets.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetView({
|
||||
sheet, doc, marks, last,
|
||||
}: {
|
||||
sheet: Sheet
|
||||
doc: BookDocument
|
||||
marks: boolean
|
||||
last: boolean
|
||||
}) {
|
||||
/*
|
||||
* Each sheet is sized to what is on it, not to the widest in the book.
|
||||
*
|
||||
* A cover is one page wide and a spread is two, and in spread mode they sit
|
||||
* in the same document. Making every sheet the wider of the two — so that one
|
||||
* `@page` rule would cover them all — put a cover in the middle of a
|
||||
* spread-sized page with white either side, which is not a cover. The print
|
||||
* CSS carries a named page rule for the narrow ones instead.
|
||||
*/
|
||||
const box = sheetBox(sheet.width, sheet.height, doc.page.bleed, marks)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bx-sheet${sheet.single ? ' is-single' : ''}`}
|
||||
data-label={sheet.label}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${box.width}mm`,
|
||||
height: `${box.height}mm`,
|
||||
background: '#ffffff',
|
||||
// The last sheet takes no break after it: an empty final page is the
|
||||
// most common defect in a browser-printed PDF.
|
||||
breakAfter: last ? 'auto' : 'page',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
The window on the spread. One page wide plus bleed on both sides, which
|
||||
is why a photograph crossing the gutter comes out aligned: both halves
|
||||
are the same spread seen through openings a page apart.
|
||||
*/}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${box.margin - box.bleed}mm`,
|
||||
top: `${box.margin - box.bleed}mm`,
|
||||
width: `${sheet.width + box.bleed * 2}mm`,
|
||||
height: `${sheet.height + box.bleed * 2}mm`,
|
||||
// The spread's own paper colour, carried out into the bleed. Without
|
||||
// it a coloured page would be cut with a white edge on three sides.
|
||||
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${box.bleed - sheet.offset}mm`,
|
||||
top: `${box.bleed}mm`,
|
||||
width: `${sheet.spreadWidth}mm`,
|
||||
height: `${sheet.height}mm`,
|
||||
}}
|
||||
>
|
||||
<SpreadView
|
||||
spread={sheet.spread}
|
||||
page={doc.page}
|
||||
spreadIndex={sheet.spreadIndex}
|
||||
big
|
||||
print
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{marks && <CropMarks box={box} width={sheet.width} height={sheet.height} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to cut.
|
||||
*
|
||||
* Eight hairlines, two per corner, starting at the bleed edge and reaching
|
||||
* outward — never crossing the trim, because a mark printed inside the trim is
|
||||
* a mark that stays in the finished book.
|
||||
*/
|
||||
function CropMarks({
|
||||
box, width, height,
|
||||
}: {
|
||||
box: ReturnType<typeof sheetBox>
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const left = box.margin
|
||||
const top = box.margin
|
||||
const right = left + width
|
||||
const bottom = top + height
|
||||
const gap = box.bleed
|
||||
|
||||
const line = (x: number, y: number, w: number, h: number, key: string) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${x}mm`,
|
||||
top: `${y}mm`,
|
||||
width: `${w}mm`,
|
||||
height: `${h}mm`,
|
||||
background: '#000000',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const marks = []
|
||||
for (const [x, dx] of [[left, -1], [right, 1]] as const) {
|
||||
for (const [y, dy] of [[top, -1], [bottom, 1]] as const) {
|
||||
// Horizontal arm, reaching out from the corner past the bleed.
|
||||
marks.push(line(
|
||||
dx < 0 ? x - gap - MARK_LENGTH : x + gap,
|
||||
y - MARK_WEIGHT / 2,
|
||||
MARK_LENGTH, MARK_WEIGHT,
|
||||
`h${x}-${y}`,
|
||||
))
|
||||
// Vertical arm.
|
||||
marks.push(line(
|
||||
x - MARK_WEIGHT / 2,
|
||||
dy < 0 ? y - gap - MARK_LENGTH : y + gap,
|
||||
MARK_WEIGHT, MARK_LENGTH,
|
||||
`v${x}-${y}`,
|
||||
))
|
||||
}
|
||||
}
|
||||
return <>{marks}</>
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { folioInk } from './folioColour'
|
||||
|
||||
/**
|
||||
* The folios.
|
||||
*
|
||||
* Drawn by the renderer rather than stored as elements, because the number a
|
||||
* page carries is a function of where the spread sits in the book — and an
|
||||
* element holding "14" would say 14 after the spread was moved, duplicated or
|
||||
* deleted. Nothing else in the document has that property, which is why this is
|
||||
* the one thing on the page that is not an element.
|
||||
*
|
||||
* That also settles where it belongs: inside `SpreadView`, so the editor and
|
||||
* the print renderer produce the same number. Stamping them with pdf-lib
|
||||
* afterwards would mean proofreading a book on screen whose page numbers are
|
||||
* not the ones that get printed.
|
||||
*
|
||||
* The cover and the back cover carry none. A folio on a cover is a mistake in
|
||||
* every book ever bound.
|
||||
*/
|
||||
export function PageNumbers({
|
||||
spread, page, spreadIndex,
|
||||
}: {
|
||||
spread: BookSpread
|
||||
page: BookPageSetup
|
||||
/** Position in the document, cover included. */
|
||||
spreadIndex: number
|
||||
}) {
|
||||
const cfg = page.pageNumbers
|
||||
if (!cfg?.show || spread.role !== 'inner') return null
|
||||
|
||||
/*
|
||||
* The cover is one sheet with a front and a back, so the first inner spread
|
||||
* opens on `startAt` — 2 by default, the page you see when you open a book
|
||||
* whose cover is page 1. `spreadIndex` counts the cover, hence the -1.
|
||||
*/
|
||||
const left = cfg.startAt + (spreadIndex - 1) * 2
|
||||
const right = left + 1
|
||||
|
||||
const size = cfg.size
|
||||
const y = page.pageHeight - cfg.margin
|
||||
|
||||
/** Where the number sits on each page, given which edge it hangs from. */
|
||||
const place = (side: 'left' | 'right') => {
|
||||
const pageX = side === 'left' ? 0 : page.pageWidth
|
||||
if (cfg.position === 'centre') {
|
||||
return { x: pageX, w: page.pageWidth, align: 'center' as const }
|
||||
}
|
||||
// Outer is the cut edge, inner is the gutter — the distinction only exists
|
||||
// on a spread, and getting it backwards puts both numbers in the fold.
|
||||
const outward = cfg.position === 'outer'
|
||||
const atLeftEdge = side === 'left' ? outward : !outward
|
||||
return atLeftEdge
|
||||
? { x: pageX + cfg.margin, w: page.pageWidth * 0.4, align: 'left' as const }
|
||||
: { x: pageX + page.pageWidth * 0.6 - cfg.margin, w: page.pageWidth * 0.4, align: 'right' as const }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{([['left', left], ['right', right]] as const).map(([side, number]) => {
|
||||
const at = place(side)
|
||||
/*
|
||||
* Sampled at the middle of the number's own box, which is where the
|
||||
* digits actually are — the box is 40% of the page wide so that the
|
||||
* text can align inside it, and its left edge is often over something
|
||||
* else entirely.
|
||||
*/
|
||||
const ink = cfg.autoColor
|
||||
? folioInk(spread, at.x + at.w / 2, y - size * 0.18)
|
||||
: { color: cfg.color, shadow: undefined }
|
||||
return (
|
||||
<div
|
||||
key={side}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${at.x}mm`,
|
||||
top: `${y}mm`,
|
||||
width: `${at.w}mm`,
|
||||
textAlign: at.align,
|
||||
fontFamily: fontStack(cfg.font),
|
||||
fontSize: `${size}pt`,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.08em',
|
||||
lineHeight: 1,
|
||||
color: ink.color,
|
||||
textShadow: ink.shadow,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{number}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BookPeer } from './useBookPresence'
|
||||
import { peerColour } from './peerColour'
|
||||
|
||||
/**
|
||||
* Who else has the book open.
|
||||
*
|
||||
* In the bar rather than in a panel, because it answers a question people ask
|
||||
* before they start moving things: am I the only one in here. A pointer answers
|
||||
* it too, but only once the other person moves — someone reading a spread in
|
||||
* silence is invisible without this.
|
||||
*
|
||||
* The same colour as their pointer, so the two read as one person.
|
||||
*/
|
||||
export function PeerBadges({ peers, t }: { peers: BookPeer[]; t: (k: string) => string }) {
|
||||
if (peers.length === 0) return null
|
||||
|
||||
/*
|
||||
* By person, not by socket.
|
||||
*
|
||||
* The presence list is keyed by socket because two tabs are two pointers, and
|
||||
* that is right for pointers. It is wrong here: one person with the book open
|
||||
* twice is one person, and showing them as two says the room is busier than
|
||||
* it is.
|
||||
*/
|
||||
const seen = new Map<number, BookPeer>()
|
||||
for (const p of peers) if (!seen.has(p.userId)) seen.set(p.userId, p)
|
||||
const people = [...seen.values()]
|
||||
|
||||
const shown = people.slice(0, 4)
|
||||
const rest = people.length - shown.length
|
||||
|
||||
return (
|
||||
<div className="st-peers" title={people.map(p => p.username).join(', ')}>
|
||||
<span className="st-peer-faces">
|
||||
{shown.map(p => (
|
||||
<span
|
||||
key={p.userId}
|
||||
className="st-peer-dot"
|
||||
style={{ background: peerColour(p.userId) }}
|
||||
aria-label={p.username}
|
||||
>
|
||||
{p.avatar
|
||||
? <img src={p.avatar} alt="" />
|
||||
: (p.username?.[0] ?? '?').toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{rest > 0 && <span className="st-peer-more">+{rest}</span>}
|
||||
<span className="st-peer-label">{t('journey.studio.peersHere')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { peerColour } from './peerColour'
|
||||
|
||||
/**
|
||||
* The other people's pointers.
|
||||
*
|
||||
* ── Why they are interpolated ────────────────────────────────────────────
|
||||
*
|
||||
* They arrive about ten times a second, because sending sixty would be five
|
||||
* times the traffic for something the eye cannot use. Drawn as they arrive,
|
||||
* that reads as a stutter; drawn towards where they last were, it reads as a
|
||||
* hand moving. The catch-up is deliberately quick — a pointer that eases
|
||||
* elegantly into place is a pointer that is telling you where somebody was a
|
||||
* third of a second ago.
|
||||
*
|
||||
* The colour is derived from the person's id — see peerColour.ts.
|
||||
*/
|
||||
|
||||
/** How much of the remaining distance is covered per frame. */
|
||||
const EASE = 0.28
|
||||
|
||||
/** Below this, in millimetres, the pointer is simply put where it belongs. */
|
||||
const SNAP_MM = 0.15
|
||||
|
||||
export function PeerCursors({
|
||||
cursors, spreadIndex, zoom,
|
||||
}: {
|
||||
cursors: PeerCursor[]
|
||||
/** Only the pointers on the spread being looked at are drawn. */
|
||||
spreadIndex: number
|
||||
/** The sheet's scale, so the arrow can undo it and stay one size. */
|
||||
zoom: number
|
||||
}) {
|
||||
const here = cursors.filter(c => c.spreadIndex === spreadIndex)
|
||||
if (here.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{here.map(cursor => (
|
||||
<Pointer key={cursor.socketId} cursor={cursor} zoom={zoom} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Pointer({ cursor, zoom }: { cursor: PeerCursor; zoom: number }) {
|
||||
const [at, setAt] = useState({ x: cursor.x, y: cursor.y })
|
||||
const target = useRef({ x: cursor.x, y: cursor.y })
|
||||
const frame = useRef<number | null>(null)
|
||||
|
||||
target.current = { x: cursor.x, y: cursor.y }
|
||||
|
||||
useEffect(() => {
|
||||
const step = () => {
|
||||
setAt(current => {
|
||||
const dx = target.current.x - current.x
|
||||
const dy = target.current.y - current.y
|
||||
if (Math.abs(dx) < SNAP_MM && Math.abs(dy) < SNAP_MM) {
|
||||
frame.current = null
|
||||
return target.current
|
||||
}
|
||||
frame.current = requestAnimationFrame(step)
|
||||
return { x: current.x + dx * EASE, y: current.y + dy * EASE }
|
||||
})
|
||||
}
|
||||
if (frame.current == null) frame.current = requestAnimationFrame(step)
|
||||
return () => {
|
||||
if (frame.current != null) cancelAnimationFrame(frame.current)
|
||||
frame.current = null
|
||||
}
|
||||
}, [cursor.x, cursor.y])
|
||||
|
||||
const colour = peerColour(cursor.userId)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="st-peer"
|
||||
style={{
|
||||
// Placed in the sheet's millimetres, which is what the position means,
|
||||
// and then scaled back out: an arrow that grew with the zoom would be a
|
||||
// postage stamp at 30% and a dinner plate at 400%.
|
||||
left: `${at.x}mm`,
|
||||
top: `${at.y}mm`,
|
||||
transform: `scale(${1 / Math.max(zoom, 0.05)})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{/*
|
||||
The arrow itself. Drawn rather than an icon so the fill can be the
|
||||
person's colour and the outline can stay white — a solid pointer
|
||||
disappears into a dark photograph, and a white one into a white page.
|
||||
*/}
|
||||
{/*
|
||||
Bigger than it first was: at 14px the 1.2 outline ate most of the fill
|
||||
and every pointer read as the same white arrow. The outline is what
|
||||
keeps it visible on a dark photograph, so the arrow grew instead.
|
||||
*/}
|
||||
<svg width="18" height="23" viewBox="0 0 14 18" fill="none">
|
||||
<path
|
||||
d="M1 1L1 14.5L4.6 11.3L7.1 16.6L9.6 15.4L7.1 10.2L11.6 10.2L1 1Z"
|
||||
fill={colour}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{cursor.username && (
|
||||
<span className="st-peer-name" style={{ background: colour }}>
|
||||
{cursor.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AlertTriangle, Check, CloudOff, RefreshCw } from 'lucide-react'
|
||||
import type { BookRecord } from '@trek/shared'
|
||||
import type { SaveState } from './useBookStore'
|
||||
|
||||
/**
|
||||
* Whether the book is safe.
|
||||
*
|
||||
* Autosave without a sign of it is a promise you are asking someone to take on
|
||||
* faith, and the moment they do not is the moment they close the tab to check.
|
||||
* So: a tick when it is written, a spinner while it is being written, and — the
|
||||
* one that matters — a real choice when someone else got there first.
|
||||
*
|
||||
* Quiet by design. This sits in the top bar of an editor, and a status that
|
||||
* announces every successful save is a status people learn to stop reading,
|
||||
* which is exactly the wrong habit for the one time it says something else.
|
||||
*/
|
||||
export function SaveIndicator({
|
||||
state, t, onAcceptTheirs, onKeepMine, onRetry,
|
||||
}: {
|
||||
state: SaveState
|
||||
t: (k: string) => string
|
||||
onAcceptTheirs: (current: BookRecord) => void
|
||||
onKeepMine: (current: BookRecord) => void
|
||||
onRetry: () => void
|
||||
}) {
|
||||
if (state.status === 'conflict') {
|
||||
return (
|
||||
<div className="st-save is-conflict" role="alert">
|
||||
<AlertTriangle size={13} />
|
||||
<span>{t('journey.studio.saveConflict')}</span>
|
||||
{/*
|
||||
Two ways out, both stated plainly. There is no merge here and
|
||||
pretending otherwise would be worse than saying so: one of these
|
||||
documents is going to be the book.
|
||||
*/}
|
||||
<button onClick={() => onAcceptTheirs(state.current)}>
|
||||
{t('journey.studio.saveTakeTheirs')}
|
||||
</button>
|
||||
<button onClick={() => onKeepMine(state.current)}>
|
||||
{t('journey.studio.saveKeepMine')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<button className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
|
||||
<CloudOff size={13} />
|
||||
<span>{t('journey.studio.saveFailed')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'saving') {
|
||||
return (
|
||||
<div className="st-save">
|
||||
<RefreshCw size={13} className="st-spin" />
|
||||
<span>{t('journey.studio.saving')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status === 'saved') {
|
||||
return (
|
||||
<div className="st-save is-quiet">
|
||||
<Check size={13} />
|
||||
<span>{t('journey.studio.saved')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type {
|
||||
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
|
||||
} from '@trek/shared'
|
||||
import { FONT_STACKS, photoSrc } from './bookRender'
|
||||
import { iconComponent } from './iconLibrary'
|
||||
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
import { PageNumbers } from './PageNumbers'
|
||||
|
||||
/**
|
||||
* One spread, drawn.
|
||||
*
|
||||
* This component is the whole reason Studio renders in DOM rather than on a
|
||||
* canvas: the *same* tree is what the print renderer will run in headless
|
||||
* Chromium. Edit mode adds handles and outlines on top; print mode is this and
|
||||
* nothing else. There is no second renderer to drift against.
|
||||
*
|
||||
* Everything is positioned in millimetres. CSS maps mm onto the PDF with a fixed
|
||||
* ratio, so what you see at `scale(0.4)` is the same box model the printer gets
|
||||
* at 1:1 — not an approximation of it.
|
||||
*/
|
||||
|
||||
function frameStyle(el: BookElement): CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: `${el.frame.x}mm`,
|
||||
top: `${el.frame.y}mm`,
|
||||
width: `${el.frame.w}mm`,
|
||||
height: `${el.frame.h}mm`,
|
||||
opacity: el.opacity,
|
||||
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Two decimals of a millimetre, matching what the document itself stores. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
|
||||
/** #rrggbb plus an alpha, for the fades a cover panel needs. */
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const n = parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
|
||||
}
|
||||
|
||||
const FILTERS: Record<string, string | undefined> = {
|
||||
none: undefined,
|
||||
bw: 'grayscale(1) contrast(1.05)',
|
||||
warm: 'saturate(1.1) sepia(0.16)',
|
||||
cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)',
|
||||
// Faded, the way a print left in the light goes: lifted blacks, less colour.
|
||||
fade: 'saturate(0.72) contrast(0.88) brightness(1.08)',
|
||||
contrast: 'contrast(1.22) saturate(1.06)',
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the frame the decoration around a picture eats, as a fraction of
|
||||
* the shorter side.
|
||||
*
|
||||
* A Polaroid's chin is the reason this is a table rather than one number: the
|
||||
* bottom border is roughly three times the others, and getting that ratio wrong
|
||||
* is the difference between a Polaroid and a photo with a white edge.
|
||||
*/
|
||||
const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
|
||||
none: { pad: 0, bottom: 0 },
|
||||
polaroid: { pad: 0.055, bottom: 0.17 },
|
||||
white: { pad: 0.035, bottom: 0.035 },
|
||||
shadow: { pad: 0, bottom: 0 },
|
||||
film: { pad: 0.075, bottom: 0.075 },
|
||||
tape: { pad: 0, bottom: 0 },
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape, drawn.
|
||||
*
|
||||
* Rectangles and ellipses stay `<div>`s — a box with a corner radius is exactly
|
||||
* what CSS is good at, and those two are what every document written before the
|
||||
* shape library existed contains. Everything else is an SVG path.
|
||||
*
|
||||
* The viewBox is in millimetres and the element is that many millimetres wide,
|
||||
* so one user unit is one millimetre and a stroke width means the same thing on
|
||||
* every side. The path is drawn into a box inset by half the stroke, which puts
|
||||
* the whole stroke inside the frame the editor snapped — the same thing
|
||||
* `box-sizing: border-box` does for the div case, and the reason a snapped edge
|
||||
* and a drawn edge line up.
|
||||
*/
|
||||
function ShapeView({ el }: { el: BookShapeElement }) {
|
||||
const fill = el.fill ?? 'transparent'
|
||||
const gradientId = `g-${el.id}`
|
||||
const gradient = el.gradient !== 'none' && el.fill
|
||||
|
||||
if (el.shape === 'rect' || el.shape === 'ellipse') {
|
||||
const background = !gradient
|
||||
? fill
|
||||
: `linear-gradient(${el.gradient === 'up' ? 'to top' : 'to bottom'},`
|
||||
+ ` ${hexToRgba(el.fill!, 0)} 0%,`
|
||||
+ ` ${hexToRgba(el.fill!, 0.55)} 46%,`
|
||||
+ ` ${hexToRgba(el.fill!, 1)} 100%)`
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle(el),
|
||||
background,
|
||||
border: el.stroke ? `${el.strokeWidth}mm ${el.strokeStyle} ${el.stroke}` : undefined,
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: el.shape === 'ellipse' ? '50%' : el.radius ? `${el.radius}mm` : undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sw = el.stroke ? el.strokeWidth : 0
|
||||
const w = Math.max(0.01, el.frame.w - sw)
|
||||
const h = Math.max(0.01, el.frame.h - sw)
|
||||
const d = scalePath(SHAPE_PATHS[el.shape], w, h)
|
||||
|
||||
// Proportional to the stroke, so a dashed outline keeps its rhythm whether it
|
||||
// is a hairline on a caption rule or a 2mm band around a cover panel.
|
||||
const dash = el.strokeStyle === 'dashed' ? `${sw * 3} ${sw * 2}`
|
||||
: el.strokeStyle === 'dotted' ? `${sw * 0.01} ${sw * 2}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div style={frameStyle(el)}>
|
||||
<svg
|
||||
width={`${el.frame.w}mm`}
|
||||
height={`${el.frame.h}mm`}
|
||||
viewBox={`0 0 ${el.frame.w} ${el.frame.h}`}
|
||||
style={{ display: 'block', overflow: 'visible' }}
|
||||
>
|
||||
{gradient && (
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1={el.gradient === 'up' ? '1' : '0'} x2="0" y2={el.gradient === 'up' ? '0' : '1'}>
|
||||
<stop offset="0%" stopColor={el.fill!} stopOpacity="0" />
|
||||
<stop offset="46%" stopColor={el.fill!} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={el.fill!} stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<path
|
||||
d={d}
|
||||
transform={sw ? `translate(${sw / 2} ${sw / 2})` : undefined}
|
||||
fill={gradient ? `url(#${gradientId})` : fill}
|
||||
fillRule={HOLED_SHAPES.has(el.shape) ? 'evenodd' : undefined}
|
||||
stroke={el.stroke ?? undefined}
|
||||
strokeWidth={sw || undefined}
|
||||
strokeDasharray={dash}
|
||||
strokeLinecap={el.strokeStyle === 'dotted' ? 'round' : undefined}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture — possibly cut to a shape, possibly sitting in something.
|
||||
*
|
||||
* The mask and the decoration are independent on purpose: a heart in a Polaroid
|
||||
* is a silly thing to want and there is no reason to stop anyone wanting it, and
|
||||
* keeping them separate means neither has to know about the other.
|
||||
*/
|
||||
function PhotoView({ el, big, print, dropLabel }: {
|
||||
el: BookPhotoElement; big: boolean; print: boolean; dropLabel: string
|
||||
}) {
|
||||
const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none
|
||||
const side = Math.min(el.frame.w, el.frame.h)
|
||||
// Rounded to a hundredth of a millimetre — the same precision the document
|
||||
// stores. Without it a fraction of the frame lands in the CSS as
|
||||
// `10.200000000000001mm`, which is not wrong but is float noise in a
|
||||
// stylesheet that a person may well end up reading.
|
||||
const pad = round2(deco.pad * side)
|
||||
const bottom = round2(deco.bottom * side)
|
||||
const clipId = `c-${el.id}`
|
||||
const clipped = el.mask && el.mask !== 'rect'
|
||||
|
||||
/*
|
||||
* An empty frame is a template's promise: it says where a picture goes before
|
||||
* anyone has chosen which — and it says it *in the frame it will wear*.
|
||||
*
|
||||
* This used to render on its own, ignoring frameStyle entirely, so a Polaroid
|
||||
* placeholder was an ordinary dashed rectangle until a photograph landed in
|
||||
* it and the chin appeared from nowhere. The two branches have been folded
|
||||
* together: the surround is drawn once, and only what goes inside it differs.
|
||||
*/
|
||||
const empty = el.photoId == null
|
||||
|
||||
// In the printed book an unfilled frame is nothing at all — a hatch and an
|
||||
// instruction on a page someone paid to have bound would be a defect.
|
||||
if (empty && print) return null
|
||||
|
||||
// The label is sized in millimetres so it scales with the zoom exactly as the
|
||||
// page does, and it steps aside on a frame too small to hold it.
|
||||
const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085))
|
||||
const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34
|
||||
|
||||
const hatch = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
/*
|
||||
* Opaque, and that is the point.
|
||||
*
|
||||
* The hatch used to be laid over nothing, so an empty frame showed
|
||||
* whatever was behind it and every attempt to arrange a shape *under*
|
||||
* one looked like it had done nothing: the frame appeared to sit at the
|
||||
* bottom of the stack no matter where it was, right up until a
|
||||
* photograph landed in it and it suddenly covered things. A frame is a
|
||||
* promise that a picture goes here, and a picture is not see-through.
|
||||
*/
|
||||
background:
|
||||
'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable — paper, not app chrome
|
||||
// A dashed border would be cut in half lengthwise by the clip, so a
|
||||
// masked placeholder shows its shape through the hatch alone.
|
||||
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2mm',
|
||||
}}
|
||||
>
|
||||
{roomy && dropLabel && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_STACKS.sans,
|
||||
fontSize: `${labelSize}mm`,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.16em',
|
||||
lineHeight: 1.5,
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(0,0,0,.34)',
|
||||
whiteSpace: 'pre-line',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{dropLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const picture = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${pad}mm`,
|
||||
top: `${pad}mm`,
|
||||
right: `${pad}mm`,
|
||||
bottom: `${bottom || pad}mm`,
|
||||
overflow: 'hidden',
|
||||
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
|
||||
clipPath: clipped ? `url(#${clipId})` : undefined,
|
||||
}}
|
||||
>
|
||||
{empty ? hatch : (
|
||||
<img
|
||||
src={photoSrc(el.photoId!, big)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: el.fit,
|
||||
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
|
||||
filter: FILTERS[el.filter],
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle(el),
|
||||
background: el.frameStyle === 'polaroid' || el.frameStyle === 'white' ? '#ffffff'
|
||||
: el.frameStyle === 'film' ? '#141414'
|
||||
: undefined,
|
||||
// A photograph lying on a page, rather than printed into it. Soft and
|
||||
// low: a hard shadow reads as a UI card, not as paper.
|
||||
boxShadow: el.frameStyle === 'shadow' || el.frameStyle === 'polaroid'
|
||||
? '0 1.2mm 3mm rgba(0,0,0,.22)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{clipped && (
|
||||
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
|
||||
<defs>
|
||||
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
|
||||
<path d={unitPath(SHAPE_PATHS[el.mask!])} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{picture}
|
||||
|
||||
{/* Sprocket holes. Drawn as a repeating gradient rather than as elements
|
||||
so the count follows the width instead of being fixed at a size that
|
||||
only looks right on one frame. */}
|
||||
{el.frameStyle === 'film' && [0, 1].map(row => (
|
||||
<div
|
||||
key={row}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${pad * 0.35}mm`,
|
||||
right: `${pad * 0.35}mm`,
|
||||
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
|
||||
height: `${pad * 0.42}mm`,
|
||||
background: 'repeating-linear-gradient(90deg,'
|
||||
+ ` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Two strips of tape, held at an angle across opposite corners. */}
|
||||
{/*
|
||||
Two strips, one over each top corner, each centred *on* its corner so it
|
||||
overhangs the picture evenly on both sides — which is what a piece of
|
||||
tape does. Offsetting by a fraction of the strip instead left them
|
||||
sitting beside the corners rather than across them.
|
||||
*/}
|
||||
{el.frameStyle === 'tape' && ([-1, 1] as const).map(dir => {
|
||||
const tapeW = side * 0.26
|
||||
const tapeH = side * 0.075
|
||||
return (
|
||||
<div
|
||||
key={dir}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
|
||||
top: `${round2(-tapeH / 2)}mm`,
|
||||
width: `${round2(tapeW)}mm`,
|
||||
height: `${round2(tapeH)}mm`,
|
||||
background: 'rgba(236,228,206,.82)',
|
||||
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
|
||||
// Away from the corner on each side: anticlockwise on the left,
|
||||
// clockwise on the right, so the pair reads as symmetric.
|
||||
transform: `rotate(${dir * 45}deg)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A lucide drawing, at the size the frame gives it.
|
||||
*
|
||||
* Kept proportional rather than stretched to the box. A shape is scaled by
|
||||
* moving its points and looks deliberate at any ratio; a stroked icon does not
|
||||
* — a stretched compass reads as a mistake — so it sits centred in whatever
|
||||
* rectangle it was given, which is also what the handles then appear to do.
|
||||
*
|
||||
* The stroke follows the drawing rather than staying a fixed width, because an
|
||||
* icon set 60mm across with a hairline is a diagram, not a mark. `lineWidth` is
|
||||
* the weight against lucide's own 24-unit grid, exactly as the icons are drawn.
|
||||
*/
|
||||
function IconView({ el }: { el: BookIconElement }) {
|
||||
const Icon = iconComponent(el.name)
|
||||
return (
|
||||
<div style={{ ...frameStyle(el), display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon
|
||||
color={el.color}
|
||||
strokeWidth={el.lineWidth}
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ElementView({
|
||||
el, big, print = false, dropLabel = '',
|
||||
}: { el: BookElement; big: boolean; print?: boolean; dropLabel?: string }) {
|
||||
if (el.kind === 'photo') {
|
||||
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
|
||||
}
|
||||
|
||||
if (el.kind === 'shape') return <ShapeView el={el} />
|
||||
|
||||
// Above the catch-all below, which routes everything that is not text into
|
||||
// the travel elements and draws null for anything they do not know.
|
||||
if (el.kind === 'icon') return <IconView el={el} />
|
||||
|
||||
if (el.kind !== 'text') {
|
||||
// map, stats, countries, badge — the elements drawn from the journey's own
|
||||
// figures. They live in their own file because they are a different kind of
|
||||
// drawing: type and vector graphics laid out from data, rather than one box
|
||||
// with one property set.
|
||||
return <TravelElementView el={el} frameStyle={frameStyle(el)} big={big} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...frameStyle(el),
|
||||
color: el.color,
|
||||
// pt, not px: the document speaks the print's language, and CSS knows
|
||||
// the conversion exactly.
|
||||
fontSize: `${el.size}pt`,
|
||||
fontFamily: FONT_STACKS[el.font],
|
||||
fontWeight: el.weight,
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
letterSpacing: `${el.tracking}em`,
|
||||
textAlign: el.align,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
hyphens: 'auto',
|
||||
}}
|
||||
>
|
||||
{el.text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet. `mode="print"` is exactly what the renderer will produce; the
|
||||
* editor draws the same thing and layers its chrome above it.
|
||||
*/
|
||||
export function SpreadView({
|
||||
spread,
|
||||
page,
|
||||
spreadIndex = 0,
|
||||
big = false,
|
||||
showGuides = false,
|
||||
print = false,
|
||||
dropLabel = '',
|
||||
}: {
|
||||
spread: BookSpread
|
||||
page: BookPageSetup
|
||||
/**
|
||||
* Where this spread sits in the book. Only the folios need it — a page
|
||||
* number is the one thing on the page that is a function of position rather
|
||||
* than of the document.
|
||||
*/
|
||||
spreadIndex?: number
|
||||
big?: boolean
|
||||
showGuides?: boolean
|
||||
/** The print renderer passes this: no guides, no placeholders, no chrome. */
|
||||
print?: boolean
|
||||
dropLabel?: string
|
||||
}) {
|
||||
const isSingle = spread.role !== 'inner'
|
||||
const w = isSingle ? page.pageWidth : page.pageWidth * 2
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
|
||||
/*
|
||||
* On screen the page ends at the trim, so anything past it is clipped.
|
||||
* In print it must not be: the bleed is the part that runs off the edge
|
||||
* and gets cut away, and clipping it here would leave a white hairline
|
||||
* along every edge of the finished book. The sheet does the clipping
|
||||
* instead — see BookSheetsView, whose window is trim plus bleed.
|
||||
*/
|
||||
overflow: print ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{spread.elements.map(el => (
|
||||
<ElementView key={el.id} el={el} big={big} print={print} dropLabel={dropLabel} />
|
||||
))}
|
||||
|
||||
<PageNumbers spread={spread} page={page} spreadIndex={spreadIndex} />
|
||||
|
||||
{showGuides && (
|
||||
<>
|
||||
{/* Safe area, per page: on a spread the inner margin belongs to the
|
||||
gutter, so one box around the whole sheet would be a lie. */}
|
||||
{(isSingle ? [0] : [0, page.pageWidth]).map(offset => (
|
||||
<div
|
||||
key={offset}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${offset + page.safe}mm`,
|
||||
top: `${page.safe}mm`,
|
||||
width: `${page.pageWidth - page.safe * 2}mm`,
|
||||
height: `${page.pageHeight - page.safe * 2}mm`,
|
||||
border: '1px dashed rgba(0,0,0,.14)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ position: 'absolute', inset: 0, width: `${w}mm`, pointerEvents: 'none' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The fold down the middle of an open book.
|
||||
*
|
||||
* Preview chrome, not content — which is why it lives outside `SpreadView`. A
|
||||
* printed book has a physical crease; a *printed* shadow down the gutter would
|
||||
* be a defect. So the editor and the page thumbnails draw this, and the renderer
|
||||
* never sees it.
|
||||
*
|
||||
* Two layers, because that is what makes paper read as curving rather than as a
|
||||
* grey stripe: the shadow ramps into the spine and darkens hard at the crease,
|
||||
* and just outside it a pale band lifts, the way the sheet catches light as it
|
||||
* comes back up out of the binding.
|
||||
*/
|
||||
export function SpreadFold({ page, scaled }: { page: BookPageSetup; scaled: number }) {
|
||||
// Wide on purpose: the lift has to ramp over a long distance to read as
|
||||
// paper curving. A narrow band reads as two painted stripes instead.
|
||||
const width = 52 * scaled
|
||||
const left = page.pageWidth * scaled - width / 2
|
||||
return (
|
||||
<div style={{ position: 'absolute', left, top: 0, width, bottom: 0, pointerEvents: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background:
|
||||
'linear-gradient(90deg,'
|
||||
+ ' rgba(255,255,255,0) 0%,'
|
||||
+ ' rgba(255,255,255,.07) 12%,'
|
||||
+ ' rgba(255,255,255,.17) 27%,'
|
||||
+ ' rgba(255,255,255,.11) 37%,'
|
||||
+ ' rgba(255,255,255,0) 43%,'
|
||||
+ ' rgba(0,0,0,.035) 45.5%,'
|
||||
+ ' rgba(0,0,0,.10) 48%,'
|
||||
+ ' rgba(0,0,0,.18) 49.6%,'
|
||||
+ ' rgba(0,0,0,.21) 50%,'
|
||||
+ ' rgba(0,0,0,.18) 50.4%,'
|
||||
+ ' rgba(0,0,0,.10) 52%,'
|
||||
+ ' rgba(0,0,0,.035) 54.5%,'
|
||||
+ ' rgba(255,255,255,0) 57%,'
|
||||
+ ' rgba(255,255,255,.11) 63%,'
|
||||
+ ' rgba(255,255,255,.17) 73%,'
|
||||
+ ' rgba(255,255,255,.07) 88%,'
|
||||
+ ' rgba(255,255,255,0) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* The crease itself. Sub-pixel at small zoom, which is right — you should
|
||||
not see a hard line on a page shown at 15%. */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: Math.max(0.5, 0.35 * scaled),
|
||||
marginLeft: -Math.max(0.25, 0.175 * scaled),
|
||||
background: 'rgba(0,0,0,.16)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronsDown, ChevronsUp, Copy, Lock, RotateCcw, RotateCw, Trash2, Unlock } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { PeerCursors } from './PeerCursors'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { FONT_STACKS } from './bookRender'
|
||||
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
|
||||
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||
|
||||
/**
|
||||
* Where the four rotation handles sit, as percentages of the selection box.
|
||||
*
|
||||
* The push diagonally outwards lives in the stylesheet rather than here: it is
|
||||
* a fixed number of screen pixels clear of the resize handle on the same
|
||||
* corner, so it must not scale with the element the way a percentage would.
|
||||
*/
|
||||
const ROTATE_CORNERS = [
|
||||
{ id: 'nw', left: '0%', top: '0%' },
|
||||
{ id: 'ne', left: '100%', top: '0%' },
|
||||
{ id: 'se', left: '100%', top: '100%' },
|
||||
{ id: 'sw', left: '0%', top: '100%' },
|
||||
] as const
|
||||
|
||||
const HANDLE_POS: Record<HandleId, { left: string; top: string; cursor: string }> = {
|
||||
nw: { left: '0%', top: '0%', cursor: 'nwse-resize' },
|
||||
n: { left: '50%', top: '0%', cursor: 'ns-resize' },
|
||||
ne: { left: '100%', top: '0%', cursor: 'nesw-resize' },
|
||||
e: { left: '100%', top: '50%', cursor: 'ew-resize' },
|
||||
se: { left: '100%', top: '100%', cursor: 'nwse-resize' },
|
||||
s: { left: '50%', top: '100%', cursor: 'ns-resize' },
|
||||
sw: { left: '0%', top: '100%', cursor: 'nesw-resize' },
|
||||
w: { left: '0%', top: '50%', cursor: 'ew-resize' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet plus everything you do to it.
|
||||
*
|
||||
* The page itself is `SpreadView`, byte for byte what the print renderer will
|
||||
* draw. Selection outlines, handles and snap guides live in a layer *above* it
|
||||
* and never touch the document, so nothing you see while editing can end up in
|
||||
* the book.
|
||||
*/
|
||||
export function StudioCanvas({
|
||||
spread,
|
||||
spreadIndex,
|
||||
page,
|
||||
zoom,
|
||||
pxPerMm,
|
||||
bookView,
|
||||
dropLabel,
|
||||
cursors,
|
||||
onCursor,
|
||||
}: {
|
||||
spread: BookSpread | null
|
||||
spreadIndex: number
|
||||
page: BookPageSetup
|
||||
zoom: number
|
||||
pxPerMm: number
|
||||
bookView: boolean
|
||||
dropLabel: string
|
||||
/** The other editors' pointers, on this spread and elsewhere. */
|
||||
cursors?: PeerCursor[]
|
||||
/** Where this one is, in the spread's millimetres. Null once it leaves. */
|
||||
onCursor?: (x: number | null, y: number | null) => void
|
||||
}) {
|
||||
const selection = useStudioStore(s => s.selection)
|
||||
const select = useStudioStore(s => s.select)
|
||||
const removeElements = useStudioStore(s => s.removeElements)
|
||||
const duplicate = useStudioStore(s => s.duplicate)
|
||||
const raise = useStudioStore(s => s.raise)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
/** Where on the spread, in millimetres, a drop landed. */
|
||||
/**
|
||||
* Turn the selection by a number of degrees.
|
||||
*
|
||||
* Kept in the range a person reads as an angle rather than as a winding
|
||||
* number: -180 to 180, so an element turned all the way round reads as
|
||||
* straight rather than as 360.
|
||||
*/
|
||||
const rotateBy = (deg: number) => {
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
...sp,
|
||||
elements: sp.elements.map(e => {
|
||||
if (!selection.includes(e.id) || e.locked) return e
|
||||
let next = (e.rotation + deg) % 360
|
||||
if (next > 180) next -= 360
|
||||
if (next < -180) next += 360
|
||||
return { ...e, rotation: Math.round(next * 10) / 10 }
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
const pointInMm = (e: React.DragEvent) => {
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
return { x: (e.clientX - r.left) / scaled, y: (e.clientY - r.top) / scaled }
|
||||
}
|
||||
|
||||
const frameUnder = (x: number, y: number) => spread?.elements.find(el =>
|
||||
el.kind === 'photo'
|
||||
&& !el.locked
|
||||
&& x >= el.frame.x && x <= el.frame.x + el.frame.w
|
||||
&& y >= el.frame.y && y <= el.frame.y + el.frame.h,
|
||||
) ?? null
|
||||
const scaled = pxPerMm * zoom
|
||||
|
||||
const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
|
||||
spread, spreadIndex, page, pxPerMm: scaled,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
|
||||
e.preventDefault()
|
||||
removeElements(spreadIndex, selection)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [selection, spreadIndex, removeElements])
|
||||
|
||||
if (!spread) return null
|
||||
|
||||
const single = spread.role !== 'inner'
|
||||
const sheetW = single ? page.pageWidth : page.pageWidth * 2
|
||||
const sel = spread.elements.filter(e => selection.includes(e.id))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="st-stage"
|
||||
style={{ width: sheetW * scaled, height: page.pageHeight * scaled }}
|
||||
onPointerMove={e => {
|
||||
onPointerMove(e)
|
||||
if (!onCursor) return
|
||||
const r = e.currentTarget.getBoundingClientRect()
|
||||
onCursor((e.clientX - r.left) / scaled, (e.clientY - r.top) / scaled)
|
||||
}}
|
||||
// Leaving the stage sends null, so the arrow goes rather than sticking
|
||||
// where the pointer happened to cross the edge.
|
||||
onPointerLeave={() => onCursor?.(null, null)}
|
||||
onPointerUp={finish}
|
||||
onPointerCancel={finish}
|
||||
onDragOver={e => {
|
||||
if (!e.dataTransfer.types.includes('application/x-trek-photo')) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
const p = pointInMm(e)
|
||||
setDropTarget(frameUnder(p.x, p.y)?.id ?? null)
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(null)}
|
||||
onDrop={e => {
|
||||
const raw = e.dataTransfer.getData('application/x-trek-photo')
|
||||
if (!raw) return
|
||||
e.preventDefault()
|
||||
setDropTarget(null)
|
||||
const photoId = Number(raw)
|
||||
if (!Number.isFinite(photoId)) return
|
||||
|
||||
const p = pointInMm(e)
|
||||
const target = frameUnder(p.x, p.y)
|
||||
if (target) {
|
||||
// Dropped onto a frame: fill it. That is the whole point of an empty
|
||||
// frame, and replacing a picture is the natural way to swap one.
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
...sp,
|
||||
elements: sp.elements.map(el => (el.id === target.id ? { ...el, photoId } : el)),
|
||||
})),
|
||||
}))
|
||||
select([target.id])
|
||||
return
|
||||
}
|
||||
|
||||
// Dropped on bare paper: a new frame, centred on the cursor.
|
||||
const w = Math.min(page.pageWidth, page.pageHeight) * 0.5
|
||||
const h = w * 0.72
|
||||
const id = `p-${Math.random().toString(36).slice(2, 9)}`
|
||||
addElement(spreadIndex, {
|
||||
id, kind: 'photo',
|
||||
frame: { x: p.x - w / 2, y: p.y - h / 2, w, h },
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
||||
mask: null, frameStyle: 'none',
|
||||
} as BookElement)
|
||||
select([id])
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="st-sheet"
|
||||
style={{
|
||||
width: `${sheetW}mm`,
|
||||
height: `${page.pageHeight}mm`,
|
||||
transform: `scale(${zoom})`,
|
||||
}}
|
||||
onPointerDown={() => select([])}
|
||||
>
|
||||
<SpreadView spread={spread} page={page} spreadIndex={spreadIndex} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
|
||||
|
||||
{cursors && cursors.length > 0 && (
|
||||
<PeerCursors cursors={cursors} spreadIndex={spreadIndex} zoom={zoom} />
|
||||
)}
|
||||
|
||||
{/* Hit targets sit above the page so a photo's own <img> never eats the
|
||||
gesture, and so a locked element simply is not grabbable. */}
|
||||
{spread.elements.map(el => (
|
||||
<div
|
||||
key={el.id}
|
||||
onPointerDown={e => { if (editing !== el.id) startMove(e, el) }}
|
||||
onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${el.frame.x}mm`,
|
||||
top: `${el.frame.y}mm`,
|
||||
width: `${el.frame.w}mm`,
|
||||
height: `${el.frame.h}mm`,
|
||||
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||
cursor: el.locked ? 'default' : 'move',
|
||||
// The target stays live even when locked, or the element could
|
||||
// not be selected — and an element that cannot be selected cannot
|
||||
// be unlocked.
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Editing happens *on the page*, in the element's own type at the element's
|
||||
own size. A dialog with a text box would be easier to build and would
|
||||
make you guess how the words break — the whole reason to type here is to
|
||||
watch the line endings while you do it.
|
||||
*/}
|
||||
{editing && (() => {
|
||||
const el = spread.elements.find(e => e.id === editing)
|
||||
if (!el || el.kind !== 'text') return null
|
||||
return (
|
||||
<textarea
|
||||
className="st-inline-edit"
|
||||
autoFocus
|
||||
defaultValue={el.text}
|
||||
style={{
|
||||
left: el.frame.x * scaled,
|
||||
top: el.frame.y * scaled,
|
||||
width: el.frame.w * scaled,
|
||||
height: el.frame.h * scaled,
|
||||
// The document is in pt; the overlay is on screen at the same zoom.
|
||||
fontSize: `${el.size * (96 / 72) * zoom}px`,
|
||||
fontFamily: FONT_STACKS[el.font],
|
||||
fontWeight: el.weight,
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
letterSpacing: `${el.tracking}em`,
|
||||
textAlign: el.align,
|
||||
color: el.color,
|
||||
}}
|
||||
onBlur={e => {
|
||||
const text = e.target.value
|
||||
setEditing(null)
|
||||
if (text !== el.text) {
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
...sp,
|
||||
elements: sp.elements.map(x => (x.id === el.id ? { ...x, text, overridden: true } : x)),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
// Escape here means "stop editing", not "close Studio" — so it must
|
||||
// not reach the shell's handler.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); (e.target as HTMLTextAreaElement).blur() }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Chrome layer: drawn in screen pixels so outlines stay hairline at any
|
||||
zoom instead of growing with the page. */}
|
||||
<div className="st-chrome">
|
||||
{bookView && !single && <SpreadFold page={page} scaled={scaled} />}
|
||||
|
||||
{/* The frame a drop would land in. Without it you are aiming blind. */}
|
||||
{dropTarget && (() => {
|
||||
const el = spread.elements.find(e => e.id === dropTarget)
|
||||
if (!el) return null
|
||||
return (
|
||||
<div
|
||||
className="st-drop"
|
||||
style={{
|
||||
left: el.frame.x * scaled,
|
||||
top: el.frame.y * scaled,
|
||||
width: el.frame.w * scaled,
|
||||
height: el.frame.h * scaled,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{sel.map(el => (
|
||||
<div
|
||||
key={el.id}
|
||||
className="st-select"
|
||||
style={{
|
||||
left: el.frame.x * scaled,
|
||||
top: el.frame.y * scaled,
|
||||
width: el.frame.w * scaled,
|
||||
height: el.frame.h * scaled,
|
||||
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||
}}
|
||||
>
|
||||
{/* No resize handles on a locked element: they would offer a drag
|
||||
that is refused, which reads as the editor being broken rather
|
||||
than as the element being locked. */}
|
||||
{/*
|
||||
A rotation handle diagonally outside each corner.
|
||||
|
||||
Four rather than one, because which corner is reachable depends on
|
||||
where the element sits: a handle off the top right alone is under
|
||||
the inspector for anything in the right page's upper half.
|
||||
|
||||
Positioned in percentages of the selection box, so they follow it
|
||||
at any zoom and turn with it once it is turned — the box itself
|
||||
carries the rotation, and everything inside it comes along.
|
||||
|
||||
Outside the box rather than on it, because a handle on the corner
|
||||
is a handle you cannot tell from the one that resizes — and this
|
||||
is the gesture where grabbing the wrong one is most annoying to
|
||||
undo. Held to 15° steps unless shift is down.
|
||||
*/}
|
||||
{sel.length === 1 && !dragging && !el.locked && ROTATE_CORNERS.map(c => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`st-rotate is-${c.id}`}
|
||||
style={{ left: c.left, top: c.top }}
|
||||
onPointerDown={startRotate}
|
||||
title="Rotate"
|
||||
>
|
||||
<RotateCw size={11} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sel.length === 1 && !dragging && !el.locked && HANDLES.map(h => (
|
||||
<span
|
||||
key={h}
|
||||
className={`st-handle ${h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v'}`}
|
||||
style={{ left: HANDLE_POS[h].left, top: HANDLE_POS[h].top, cursor: HANDLE_POS[h].cursor }}
|
||||
onPointerDown={e => startResize(e, h)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/*
|
||||
The quick bar, the way Canva and Figma do it: the three or four things
|
||||
you reach for constantly, right where you are already looking, instead
|
||||
of a trip to the panel on the far side of the screen. It hides while
|
||||
you drag — chrome that follows your hand around is noise.
|
||||
*/}
|
||||
{sel.length >= 1 && !dragging && (() => {
|
||||
const x0 = Math.min(...sel.map(e => e.frame.x)) * scaled
|
||||
const x1 = Math.max(...sel.map(e => e.frame.x + e.frame.w)) * scaled
|
||||
const y0 = Math.min(...sel.map(e => e.frame.y)) * scaled
|
||||
const locked = sel.every(e => e.locked)
|
||||
return (
|
||||
<div
|
||||
className="st-quickbar"
|
||||
style={{ left: (x0 + x1) / 2, top: Math.max(6, y0 - 12) }}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
>
|
||||
<button onClick={() => duplicate(spreadIndex, selection)} title="Duplicate">
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
{sel.length === 1 && (
|
||||
<>
|
||||
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title="Bring to front">
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title="Send to back">
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/*
|
||||
Turn it, a step at a time.
|
||||
|
||||
Fifteen degrees per press, and a press with shift is one degree
|
||||
for the times it has to line up with something. Steps rather
|
||||
than a handle you drag: a photograph on a page is almost always
|
||||
either square to it or tilted a little on purpose, and dragging
|
||||
a rotation past the angle you wanted and back is a worse way to
|
||||
arrive at either.
|
||||
*/}
|
||||
<button
|
||||
onClick={e => rotateBy(e.shiftKey ? -1 : -15)}
|
||||
title="Rotate left"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => rotateBy(e.shiftKey ? 1 : 15)}
|
||||
title="Rotate right"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
<span className="st-quickbar-sep" />
|
||||
<button
|
||||
onClick={() => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
...sp,
|
||||
elements: sp.elements.map(e => (selection.includes(e.id) ? { ...e, locked: !locked } : e)),
|
||||
})),
|
||||
}))}
|
||||
title={locked ? 'Unlock' : 'Lock'}
|
||||
>
|
||||
{locked ? <Unlock size={14} /> : <Lock size={14} />}
|
||||
</button>
|
||||
<span className="st-quickbar-sep" />
|
||||
<button className="is-danger" onClick={() => removeElements(spreadIndex, selection)} title="Delete">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{guides.map((g, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="st-guide"
|
||||
style={g.axis === 'x'
|
||||
? { left: g.at * scaled, top: 0, width: 1, height: '100%' }
|
||||
: { top: g.at * scaled, left: 0, height: 1, width: '100%' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { BookElement }
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* The inspector's vocabulary.
|
||||
*
|
||||
* ── Why this file exists ─────────────────────────────────────────────────
|
||||
*
|
||||
* The panel had grown four ways of saying "this one is on" — a filled pill, a
|
||||
* ring, a tinted border, ink on a tint — and used the *same* pill for a choice
|
||||
* between four things and for a switch that turns one thing off. A map showed
|
||||
* seventy controls in ten sections that were all open at once, all labelled in
|
||||
* the same small capitals, so nothing on the panel looked more important than
|
||||
* anything else. None of that was a decision; it was what happens when a panel
|
||||
* grows one control at a time.
|
||||
*
|
||||
* So there are now exactly three ways to answer a question here, and each looks
|
||||
* like what it is:
|
||||
*
|
||||
* **Choose one** is a row of chips, and the chosen one is filled.
|
||||
* **Choose several** is a row of chips that carry a tick when they are on,
|
||||
* because a set with three of seven picked has to be readable at a glance.
|
||||
* **On or off** is the app's switch, on its own line beside the words —
|
||||
* the one control in TREK that already means exactly that.
|
||||
*
|
||||
* A section can be folded away, which is the other half of the answer: a
|
||||
* property panel is a reference, and a reference nobody can collapse is a wall.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One group of properties.
|
||||
*
|
||||
* `defaultOpen={false}` is for the groups you set once and rarely return to —
|
||||
* the frame's numbers, mostly. The state is per mounted section rather than
|
||||
* remembered: coming back to an element and finding its panel folded up
|
||||
* differently from last time is worse than opening one group again.
|
||||
*/
|
||||
export function Section({ label, children, defaultOpen = true, hint }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
defaultOpen?: boolean
|
||||
/** A line under the label, for a group whose name cannot carry the whole idea. */
|
||||
hint?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
||||
<button className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||
<span className="st-section-label">{label}</span>
|
||||
<ChevronDown size={13} className="st-section-caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="st-section-body">
|
||||
{hint && <p className="st-hint st-section-hint">{hint}</p>}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A labelled line. The label sits left, the control fills what is left. */
|
||||
export function Line({ label, children, wide = false }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
/** Put the control under the label rather than beside it, when it needs the width. */
|
||||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`st-line ${wide ? 'is-wide' : ''}`}>
|
||||
<span className="st-line-label">{label}</span>
|
||||
<div className="st-line-body">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Choose one of a few. The chosen one is filled in. */
|
||||
export function Choice<T extends string | number>({ value, options, onPick, ariaLabel }: {
|
||||
value: T
|
||||
options: { value: T; label: ReactNode; title?: string; disabled?: boolean }[]
|
||||
onPick: (v: T) => void
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="st-row" role="radiogroup" aria-label={ariaLabel}>
|
||||
{options.map(o => (
|
||||
<button
|
||||
key={String(o.value)}
|
||||
role="radio"
|
||||
aria-checked={o.value === value}
|
||||
className={`st-chip ${o.value === value ? 'is-on' : ''}`}
|
||||
disabled={o.disabled}
|
||||
title={o.title}
|
||||
onClick={() => onPick(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose several. A tick marks what is in the set.
|
||||
*
|
||||
* Not switches: seven metrics as seven switched lines is a column of switches
|
||||
* taller than the element it configures, and what matters about a set is which
|
||||
* of it is in — a question a row answers and a list does not.
|
||||
*/
|
||||
export function Picks<T extends string>({ value, options, onToggle }: {
|
||||
value: readonly T[]
|
||||
options: { value: T; label: ReactNode; disabled?: boolean }[]
|
||||
onToggle: (v: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="st-row">
|
||||
{options.map(o => {
|
||||
const on = value.includes(o.value)
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
className={`st-chip is-pick ${on ? 'is-on' : ''}`}
|
||||
disabled={o.disabled}
|
||||
onClick={() => onToggle(o.value)}
|
||||
>
|
||||
{on && <Check size={12} strokeWidth={3} />}
|
||||
{o.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** On or off, said the way the rest of TREK says it. */
|
||||
export function Switch({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="st-switch">
|
||||
<span className="st-switch-label">{label}</span>
|
||||
<ToggleSwitch on={on} onToggle={onToggle} label={label} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A number, with its unit.
|
||||
*
|
||||
* Held as text while it is being typed and only read back on blur or Enter,
|
||||
* which fixes three things at once: "0." no longer snaps to "0" the moment the
|
||||
* point is typed, an emptied field no longer collapses the element to zero,
|
||||
* and a value is one undo step rather than one per keystroke.
|
||||
*/
|
||||
export function NumField({
|
||||
label, value, onChange, min, max, step = 0.5, unit,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
}) {
|
||||
const shown = String(Math.round(value * 100) / 100)
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
|
||||
const commit = (raw: string) => {
|
||||
setDraft(null)
|
||||
const v = Number(raw)
|
||||
if (raw.trim() === '' || !Number.isFinite(v)) return
|
||||
const clamped = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, v))
|
||||
if (clamped !== value) onChange(clamped)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="st-num">
|
||||
<span className="st-num-label">{label}</span>
|
||||
<span className="st-num-box">
|
||||
<input
|
||||
className="st-input"
|
||||
type="number"
|
||||
value={draft ?? shown}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={e => commit(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
|
||||
if (e.key === 'Escape') setDraft(null)
|
||||
}}
|
||||
/>
|
||||
{unit && <span className="st-num-unit">{unit}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Minus, Quote, Search, Square, X } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
|
||||
import { GRIDS, defaultGridBox, gridElements } from './grids'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
|
||||
/**
|
||||
* Everything you can add that does not come from the journey: type, shapes,
|
||||
* picture frames, photo grids and the icon set.
|
||||
*
|
||||
* Four tabs rather than four more entries on the rail. They are all the same
|
||||
* kind of answer to "what do I put on this page", and a rail long enough to
|
||||
* hold them separately stops being faster to hit than a word would be.
|
||||
*/
|
||||
|
||||
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
/** How many icons the grid draws at a time. Two screens' worth at four across. */
|
||||
const ICON_PAGE = 120
|
||||
|
||||
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
|
||||
|
||||
/**
|
||||
* A shape as a tile.
|
||||
*
|
||||
* The same path the page will draw, so what you pick is what you get. In frame
|
||||
* mode it is filled with a sky-and-hill gradient, which is Canva's trick and a
|
||||
* good one: an outline tells you the silhouette, but only something
|
||||
* photograph-shaped inside it says "a picture goes here".
|
||||
*/
|
||||
export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo?: boolean }) {
|
||||
const gid = `sg-${photo ? 'p' : 's'}-${shape}`
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-hidden>
|
||||
{photo && (
|
||||
<defs>
|
||||
{/* theme-lint-disable — a frame preview stands in for a photograph, not for UI chrome */}
|
||||
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#cfe8f7" />
|
||||
<stop offset="52%" stopColor="#e8f2f8" />
|
||||
<stop offset="53%" stopColor="#8fbf59" />
|
||||
<stop offset="100%" stopColor="#5d8f33" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<path
|
||||
d={SHAPE_PATHS[shape]}
|
||||
fill={photo ? `url(#${gid})` : 'currentColor'}
|
||||
fillRule={HOLED_SHAPES.has(shape) ? 'evenodd' : undefined}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** A miniature of what the decoration around a picture does. */
|
||||
function FrameStyleGlyph({ style }: { style: FrameStyle }) {
|
||||
const pad = style === 'polaroid' ? 3 : style === 'white' ? 2 : style === 'film' ? 4 : 0
|
||||
const bottom = style === 'polaroid' ? 9 : pad
|
||||
return (
|
||||
<span
|
||||
className="st-frame-glyph"
|
||||
style={{
|
||||
// theme-lint-disable — a frame preview shows paper and film, not themed surfaces
|
||||
background: style === 'film' ? '#141414'
|
||||
: style === 'polaroid' || style === 'white' ? '#ffffff'
|
||||
: 'transparent',
|
||||
boxShadow: style === 'shadow' || style === 'polaroid' ? '0 2px 4px rgba(0,0,0,.35)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="st-frame-glyph-photo" style={{ inset: `${pad}px ${pad}px ${bottom}px ${pad}px` }} />
|
||||
{/* Both strips, because both is what gets placed — a preview showing one
|
||||
made the second one look like a bug the first time it appeared. */}
|
||||
{style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
|
||||
const Icon = iconComponent(name)
|
||||
return (
|
||||
<button
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => onPick(name)}
|
||||
aria-label={name}
|
||||
title={iconLabel(name)}
|
||||
>
|
||||
<Icon strokeWidth={1.7} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
|
||||
const [tab, setTab] = useState<'shapes' | 'frames' | 'grids' | 'icons'>('shapes')
|
||||
const [iconQuery, setIconQuery] = useState('')
|
||||
/*
|
||||
* How much of the library is on screen.
|
||||
*
|
||||
* Fourteen hundred icons is fourteen hundred React components, each a
|
||||
* forwardRef around several SVG nodes, and rendering them all costs about a
|
||||
* second of blocked main thread on the first click of the tab — for a grid
|
||||
* whose first two rows are all anybody sees. So it grows as it is scrolled,
|
||||
* and a search resets it: the results of a search are short, and starting
|
||||
* them part-drawn would be wrong.
|
||||
*/
|
||||
const [iconLimit, setIconLimit] = useState(ICON_PAGE)
|
||||
const iconTail = useRef<HTMLDivElement>(null)
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const select = useStudioStore(s => s.select)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial<BookElement> = {}) =>
|
||||
addElement(active, {
|
||||
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
text: sample, font: 'sans', size, weight, italic: false,
|
||||
align: 'left', leading: size > 16 ? 1.1 : 1.5, tracking: size > 16 ? -0.02 : 0,
|
||||
color: '#1a1a1a', binding: null, overridden: false, ...extra,
|
||||
} as BookElement)
|
||||
|
||||
/**
|
||||
* Placed at a size that suits the shape rather than always as a square.
|
||||
*
|
||||
* A banner wants to be wide and a pin wants to be tall; dropping every shape
|
||||
* as one 60mm square means resizing each one before it is any use, which is
|
||||
* a tax on the most common action in the panel.
|
||||
*/
|
||||
const addShape = (shape: BookShapeId, opts: { outline?: boolean } = {}) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3
|
||||
const wide = shape.startsWith('banner') || shape === 'ticket' || shape === 'wave'
|
||||
|| shape === 'arrow-right' || shape === 'arrow-left' || shape === 'arrow-both'
|
||||
|| shape === 'capsule'
|
||||
const tall = shape === 'pin' || shape === 'drop' || shape === 'arrow-up' || shape === 'arrow-down'
|
||||
const isRule = shape === 'line'
|
||||
addElement(active, {
|
||||
id: uid('s'), kind: 'shape',
|
||||
frame: isRule
|
||||
? centre(page.pageWidth * 0.5, 0.5)
|
||||
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
// A rule is a shape too — a hairline box rather than its own element type,
|
||||
// so it moves, colours and snaps like everything else on the page.
|
||||
shape: isRule ? 'rect' : shape,
|
||||
fill: opts.outline ? null : '#141414',
|
||||
gradient: 'none',
|
||||
stroke: opts.outline ? '#141414' : null,
|
||||
strokeWidth: opts.outline ? 0.5 : 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon, placed as a square.
|
||||
*
|
||||
* Square because that is what it is: unlike a shape, an icon keeps its
|
||||
* proportions inside whatever frame it is given, so a rectangle would only
|
||||
* pad it with air on two sides.
|
||||
*/
|
||||
const addIcon = (name: string) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
|
||||
addElement(active, {
|
||||
id: uid('i'), kind: 'icon', frame: centre(side, side),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
name, color: '#111827', lineWidth: 2,
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
/** An empty frame, cut to a shape and waiting for a picture. */
|
||||
const addFrame = (mask: BookShapeId | null, frameStyle: FrameStyle = 'none') => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.45
|
||||
addElement(active, {
|
||||
id: uid('p'), kind: 'photo',
|
||||
// A Polaroid is taller than it is wide, because of the chin. Placing it
|
||||
// square would put the picture in a box the decoration then eats.
|
||||
frame: centre(side, frameStyle === 'polaroid' ? side * 1.16 : side),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
photoId: null, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
||||
mask, frameStyle,
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery])
|
||||
const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit])
|
||||
|
||||
/*
|
||||
* Grow the grid when its foot comes into view.
|
||||
*
|
||||
* An observer rather than a scroll handler: the panel is the scroller, the
|
||||
* foot is the thing that matters, and asking the browser to tell us when they
|
||||
* meet costs nothing per frame.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const tail = iconTail.current
|
||||
if (!tail) return
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE)
|
||||
})
|
||||
io.observe(tail)
|
||||
return () => io.disconnect()
|
||||
}, [shownIcons.length, matchedIcons.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.elements')} />
|
||||
|
||||
<div className="st-tabs">
|
||||
<button className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
|
||||
{t('journey.studio.shapes')}
|
||||
</button>
|
||||
<button className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}>
|
||||
{t('journey.studio.frames')}
|
||||
</button>
|
||||
<button className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
|
||||
{t('journey.studio.grids')}
|
||||
</button>
|
||||
<button className={tab === 'icons' ? 'is-on' : ''} onClick={() => setTab('icons')}>
|
||||
{t('journey.studio.icons')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'shapes' && (
|
||||
<>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.text')}</div>
|
||||
<div className="st-stack">
|
||||
<button className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
|
||||
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
|
||||
{t('journey.studio.styleTitle')}
|
||||
</span>
|
||||
</button>
|
||||
<button className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
|
||||
</button>
|
||||
<button className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
|
||||
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="st-tile"
|
||||
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
|
||||
>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||
{t('journey.studio.styleCaption')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.lines')}</div>
|
||||
<div className="st-shape-grid">
|
||||
<button className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
|
||||
<Minus size={20} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="st-shape-btn"
|
||||
onClick={() => addShape('rect', { outline: true })}
|
||||
title={t('journey.studio.shapeKind.outline')}
|
||||
>
|
||||
<Square size={20} strokeWidth={1.1} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
<button
|
||||
className="st-shape-btn"
|
||||
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
|
||||
title={t('journey.studio.quoteMark')}
|
||||
>
|
||||
<Quote size={20} strokeWidth={1.6} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{SHAPE_GROUPS.map(group => (
|
||||
<div className="st-section" key={group.id}>
|
||||
<div className="st-section-label">{t(`journey.studio.shapeGroup.${group.id}`)}</div>
|
||||
<div className="st-shape-grid">
|
||||
{group.shapes.map(shape => (
|
||||
<button
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addShape(shape)}
|
||||
aria-label={shape}
|
||||
>
|
||||
<ShapeGlyph shape={shape} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'frames' && (
|
||||
<>
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.frameStyles')}</div>
|
||||
<div className="st-shape-grid">
|
||||
{([
|
||||
['none', 'plainFrame'],
|
||||
['polaroid', 'polaroidFrame'],
|
||||
['white', 'whiteFrame'],
|
||||
['shadow', 'shadowFrame'],
|
||||
['film', 'filmFrame'],
|
||||
['tape', 'tapeFrame'],
|
||||
] as [FrameStyle, string][]).map(([style, key]) => (
|
||||
<button
|
||||
key={style}
|
||||
className="st-shape-btn is-frame"
|
||||
onClick={() => addFrame(null, style)}
|
||||
title={t(`journey.studio.${key}`)}
|
||||
aria-label={t(`journey.studio.${key}`)}
|
||||
>
|
||||
<FrameStyleGlyph style={style} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.frameShapes')}</div>
|
||||
<div className="st-shape-grid">
|
||||
{FRAME_SHAPES.map(shape => (
|
||||
<button
|
||||
key={shape}
|
||||
className="st-shape-btn is-glyph"
|
||||
onClick={() => addFrame(shape === 'rect' ? null : shape)}
|
||||
aria-label={shape}
|
||||
>
|
||||
<ShapeGlyph shape={shape} photo />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.frameHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'grids' && (
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.grids')}</div>
|
||||
<div className="st-grid-grid">
|
||||
{GRIDS.map(grid => (
|
||||
<button
|
||||
key={grid.id}
|
||||
className="st-grid-btn"
|
||||
onClick={() => {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) =>
|
||||
(i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
}))
|
||||
// Selected as a group, so the block can be moved or resized
|
||||
// as one thing straight away rather than cell by cell.
|
||||
select(els.map(e => e.id))
|
||||
}}
|
||||
aria-label={grid.id}
|
||||
title={`${grid.cells.length}`}
|
||||
>
|
||||
{/* The arrangement itself: "hero-two" means nothing until you
|
||||
have seen it, and a diagram is faster to read than a name. */}
|
||||
<span className="st-grid-preview">
|
||||
{grid.cells.map((cell, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
left: `${cell.x * 100}%`,
|
||||
top: `${cell.y * 100}%`,
|
||||
width: `${cell.w * 100}%`,
|
||||
height: `${cell.h * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.gridHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'icons' && (
|
||||
<div className="st-section">
|
||||
{/*
|
||||
The search sits inside the scroller rather than above the tab
|
||||
strip, unlike the Content panel's: that one filters both of its
|
||||
tabs, this one belongs to a single tab, and hanging it above the
|
||||
strip would make the strip move when you switch away from it.
|
||||
*/}
|
||||
<div className="st-search" style={{ margin: '0 0 10px' }}>
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={iconQuery}
|
||||
onChange={e => { setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }}
|
||||
placeholder={t('journey.studio.searchIcons')}
|
||||
aria-label={t('journey.studio.searchIcons')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{iconQuery && (
|
||||
<button onClick={() => { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* The shelf, until somebody searches. */}
|
||||
{!iconQuery && (
|
||||
<>
|
||||
<div className="st-section-label">{t('journey.studio.iconsForTravel')}</div>
|
||||
<div className="st-shape-grid" style={{ marginBottom: 14 }}>
|
||||
{FEATURED_ICONS.map(name => (
|
||||
<IconCell key={name} name={name} onPick={addIcon} />
|
||||
))}
|
||||
</div>
|
||||
<div className="st-section-label">{t('journey.studio.iconsAll')}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="st-shape-grid">
|
||||
{shownIcons.map(name => (
|
||||
<IconCell key={name} name={name} onPick={addIcon} />
|
||||
))}
|
||||
</div>
|
||||
{/* What the observer below watches for. */}
|
||||
{shownIcons.length < matchedIcons.length && <div ref={iconTail} style={{ height: 1 }} />}
|
||||
{!matchedIcons.length && (
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.noMatches')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, FileText, Printer, Scissors, X } from 'lucide-react'
|
||||
import type { BookDocument } from '@trek/shared'
|
||||
import { BookSheetsView } from './BookSheetsView'
|
||||
import { sheetBox, sheetsFor, type SheetMode } from './bookSheets'
|
||||
import { printSheets } from './printSheets'
|
||||
|
||||
/**
|
||||
* Getting the book out.
|
||||
*
|
||||
* ── Two questions, not a settings panel ──────────────────────────────────
|
||||
*
|
||||
* Leaves or spreads, and marks or no marks. Everything else a print dialog
|
||||
* usually asks — resolution, colour profile, embedding — either has one right
|
||||
* answer here or is not ours to give: the images go out at full size because
|
||||
* anything else is a worse book, and the profile belongs to whoever is doing
|
||||
* the printing.
|
||||
*
|
||||
* ── Why the sheets are rendered here rather than built as a string ───────
|
||||
*
|
||||
* They are the editor's own components, and those read context — the active
|
||||
* locale, most visibly, which is what a stats element and a date line are
|
||||
* written in. Rendering them to markup outside the tree would mean standing up
|
||||
* that context again and getting a book in English for someone who wrote it in
|
||||
* German. So they render inside the app, off screen, and the markup is taken
|
||||
* from the DOM afterwards.
|
||||
*/
|
||||
export function StudioExport({
|
||||
doc, title, t, onClose,
|
||||
}: {
|
||||
doc: BookDocument
|
||||
title: string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [mode, setMode] = useState<SheetMode>('pages')
|
||||
const [marks, setMarks] = useState(true)
|
||||
/** Set once the user has asked for it — this is what triggers the render. */
|
||||
const [building, setBuilding] = useState(false)
|
||||
const stage = useRef<HTMLDivElement>(null)
|
||||
|
||||
const sheets = sheetsFor(doc, mode)
|
||||
|
||||
/*
|
||||
* Two sizes, because spread mode mixes them: covers are one page and
|
||||
* everything between them is two. The wider one is the document's page box
|
||||
* and the narrower gets a named rule — see printSheets.
|
||||
*/
|
||||
const widest = Math.max(...sheets.map(s => s.width), doc.page.pageWidth)
|
||||
const box = sheetBox(widest, doc.page.pageHeight, doc.page.bleed, marks)
|
||||
const single = sheetBox(doc.page.pageWidth, doc.page.pageHeight, doc.page.bleed, marks)
|
||||
|
||||
useEffect(() => {
|
||||
if (!building) return
|
||||
const html = stage.current?.innerHTML
|
||||
if (!html) return
|
||||
|
||||
printSheets({
|
||||
html,
|
||||
sheetWidth: box.width,
|
||||
sheetHeight: box.height,
|
||||
singleWidth: single.width,
|
||||
singleHeight: single.height,
|
||||
title,
|
||||
labels: {
|
||||
save: t('journey.studio.exportSave'),
|
||||
close: t('common.close'),
|
||||
count: t('journey.studio.exportSheetCount', { count: sheets.length }),
|
||||
preparing: t('journey.studio.exportPreparing'),
|
||||
},
|
||||
})
|
||||
setBuilding(false)
|
||||
onClose()
|
||||
// Runs once per build. Re-running on every render of the options would
|
||||
// open a second print view behind the first.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [building])
|
||||
|
||||
return (
|
||||
<div className="st-ex" role="dialog" aria-modal="true" aria-label={t('journey.studio.export')}>
|
||||
<div className="st-ex-card">
|
||||
<div className="st-ex-head">
|
||||
<span>{t('journey.studio.export')}</span>
|
||||
<button className="st-ex-x" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="st-ex-body">
|
||||
<Choice<SheetMode>
|
||||
label={t('journey.studio.exportLayout')}
|
||||
options={[
|
||||
{
|
||||
id: 'pages',
|
||||
icon: FileText,
|
||||
name: t('journey.studio.exportPages'),
|
||||
hint: t('journey.studio.exportPagesHint'),
|
||||
},
|
||||
{
|
||||
id: 'spreads',
|
||||
icon: BookOpen,
|
||||
name: t('journey.studio.exportSpreads'),
|
||||
hint: t('journey.studio.exportSpreadsHint'),
|
||||
},
|
||||
]}
|
||||
value={mode}
|
||||
onPick={setMode}
|
||||
/>
|
||||
|
||||
<div className="st-ex-field">
|
||||
<span className="st-ex-label">{t('journey.studio.exportFinishing')}</span>
|
||||
<button
|
||||
className={`st-ex-opt ${marks ? 'is-on' : ''}`}
|
||||
onClick={() => setMarks(!marks)}
|
||||
aria-pressed={marks}
|
||||
>
|
||||
<Scissors size={15} />
|
||||
<span className="st-ex-text">
|
||||
<span className="st-ex-name">{t('journey.studio.exportMarks')}</span>
|
||||
<span className="st-ex-hint">
|
||||
{t('journey.studio.exportMarksHint', { bleed: doc.page.bleed })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="st-ex-note">
|
||||
{t('journey.studio.exportNote', {
|
||||
sheets: sheets.length,
|
||||
width: round1(box.width),
|
||||
height: round1(box.height),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="st-ex-foot">
|
||||
<button className="st-ex-btn" onClick={onClose}>{t('common.cancel')}</button>
|
||||
<button className="st-ex-btn is-primary" onClick={() => setBuilding(true)} disabled={building}>
|
||||
<Printer size={14} />
|
||||
<span>{t('journey.studio.exportOpen')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The sheets, rendered where nobody can see them.
|
||||
Off screen rather than `display: none`: a hidden subtree lays nothing
|
||||
out, and these are measured in millimetres by the same CSS that will
|
||||
print them — a book built from an unlaid-out tree is a book of empty
|
||||
boxes. `aria-hidden` keeps the whole thing out of the reading order.
|
||||
*/}
|
||||
{building && (
|
||||
<div
|
||||
ref={stage}
|
||||
aria-hidden="true"
|
||||
style={{ position: 'fixed', left: '-20000mm', top: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<BookSheetsView doc={doc} mode={mode} marks={marks} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10
|
||||
|
||||
function Choice<T extends string>({
|
||||
label, options, value, onPick,
|
||||
}: {
|
||||
label: string
|
||||
options: { id: T; icon: typeof FileText; name: string; hint: string }[]
|
||||
value: T
|
||||
onPick: (id: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="st-ex-field">
|
||||
<span className="st-ex-label">{label}</span>
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
className={`st-ex-opt ${value === opt.id ? 'is-on' : ''}`}
|
||||
onClick={() => onPick(opt.id)}
|
||||
aria-pressed={value === opt.id}
|
||||
>
|
||||
<opt.icon size={15} />
|
||||
<span className="st-ex-text">
|
||||
<span className="st-ex-name">{opt.name}</span>
|
||||
<span className="st-ex-hint">{opt.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
|
||||
ChevronsDown, ChevronsUp, Copy, Italic, Lock, Trash2, Unlock,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
|
||||
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
|
||||
import { ShapeGlyph } from './StudioElementsPanel'
|
||||
import { TravelInspector } from './StudioTravelInspector'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { formatBookCoords } from './entryText'
|
||||
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
|
||||
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
|
||||
/** How many icons the swap grid offers at once. A search narrows it below this. */
|
||||
const ICON_CHOICES = 60
|
||||
|
||||
/**
|
||||
* Properties of whatever is selected.
|
||||
*
|
||||
* ── What it shows, and in what order ─────────────────────────────────────
|
||||
*
|
||||
* Deliberately narrow: what the selected thing actually has, and nothing else.
|
||||
* A panel that lists every property of every element with most of them greyed
|
||||
* out reads as a settings dialog, not as a tool.
|
||||
*
|
||||
* The order is the same for every kind, because the panel is used by muscle
|
||||
* memory more than by reading: **what it says** first, then **how it looks**,
|
||||
* then **where it sits**, then **what is in front of what**. A photo's crop and
|
||||
* a mark's words are the same step in that sequence, so they sit in the same
|
||||
* place on the panel.
|
||||
*
|
||||
* The frame's numbers are folded away by default. They are the least-used group
|
||||
* in the panel — dragging is how anyone actually positions an element — and
|
||||
* four number fields left open at the top pushed everything worth looking at
|
||||
* below the fold.
|
||||
*
|
||||
* ── What is not here any more ────────────────────────────────────────────
|
||||
*
|
||||
* Duplicate, lock and delete are in the panel head rather than in a section of
|
||||
* their own: they act on the element rather than describing it, and a group
|
||||
* called "Arrange" that held two of those next to the stacking buttons was the
|
||||
* one group in the panel whose name did not say what was inside it.
|
||||
*/
|
||||
|
||||
export function StudioInspector({
|
||||
spreadIndex,
|
||||
page,
|
||||
stats,
|
||||
source,
|
||||
setPageNumbers,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
spreadIndex: number
|
||||
page: BookPageSetup
|
||||
setPageNumbers: (patch: Partial<BookPageNumbers>) => void
|
||||
/** Live journey figures, so a travel element can be brought up to date. */
|
||||
stats: JourneyStats | null
|
||||
/** The journal itself, so a bound mark can be re-set from it. */
|
||||
source: JourneySource
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
}) {
|
||||
const [iconQuery, setIconQuery] = useState('')
|
||||
/*
|
||||
* A page of results, memoised because the inspector re-renders on every
|
||||
* pointer move of a drag and the library is fourteen hundred names long.
|
||||
*/
|
||||
const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery])
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const selection = useStudioStore(s => s.selection)
|
||||
const update = useStudioStore(s => s.updateElement)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const raise = useStudioStore(s => s.raise)
|
||||
const duplicate = useStudioStore(s => s.duplicate)
|
||||
const removeElements = useStudioStore(s => s.removeElements)
|
||||
|
||||
const spread = doc?.spreads[spreadIndex]
|
||||
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
|
||||
|
||||
if (!sel.length) {
|
||||
/*
|
||||
* Nothing selected, so this is where the *book's* own settings belong.
|
||||
* The panel was showing one line of hint text and nothing else, and page
|
||||
* numbers have nowhere else to go: they are not a property of any element,
|
||||
* because the number a page carries depends on where its spread sits.
|
||||
*/
|
||||
const folios = page.pageNumbers
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
|
||||
<div className="st-panel-scroll">
|
||||
<Section label={t('journey.studio.pageNumbers')}>
|
||||
<Switch
|
||||
label={t('journey.studio.pageNumbers')}
|
||||
on={folios.show}
|
||||
onToggle={() => setPageNumbers({ show: !folios.show })}
|
||||
/>
|
||||
|
||||
{folios.show && (
|
||||
<>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.position')}>
|
||||
<Choice
|
||||
value={folios.position}
|
||||
options={(['outer', 'inner', 'centre'] as const).map(pos => ({
|
||||
value: pos, label: t(`journey.studio.folio.${pos}`),
|
||||
}))}
|
||||
onPick={position => setPageNumbers({ position })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.folioStart')}
|
||||
value={folios.startAt}
|
||||
min={0}
|
||||
max={9999}
|
||||
step={1}
|
||||
onChange={v => setPageNumbers({ startAt: Math.round(v) })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.size')}
|
||||
value={folios.size}
|
||||
min={4}
|
||||
max={48}
|
||||
step={0.5}
|
||||
unit="pt"
|
||||
onChange={v => setPageNumbers({ size: v })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.folioMargin')}
|
||||
value={folios.margin}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => setPageNumbers({ margin: v })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.folioAuto')}
|
||||
on={folios.autoColor}
|
||||
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
|
||||
/>
|
||||
</div>
|
||||
{/* Picking a colour is the other way to turn automatic off, so
|
||||
the row stays reachable and simply reads as inactive. */}
|
||||
<div style={{ marginTop: 10, opacity: folios.autoColor ? 0.45 : 1 }}>
|
||||
<Line label={t('journey.studio.colour')} wide>
|
||||
<Swatches
|
||||
value={folios.color}
|
||||
onPick={c => setPageNumbers({ color: c, autoColor: false })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<p className="st-hint" style={{ paddingTop: 10 }}>
|
||||
{t('journey.studio.inspectorEmpty')}
|
||||
</p>
|
||||
<p className="st-hint" style={{ paddingTop: 6 }}>
|
||||
{t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const el = sel[0]
|
||||
const one = sel.length === 1
|
||||
|
||||
// A property edit is one undo step on its own; only drags open a gesture.
|
||||
const set = (patch: Partial<BookElement>) => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
|
||||
...sp,
|
||||
elements: sp.elements.map(e => (selection.includes(e.id) ? ({ ...e, ...patch } as BookElement) : e)),
|
||||
})),
|
||||
}))
|
||||
|
||||
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
|
||||
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
{/*
|
||||
The head says what is selected and carries the three things you do TO
|
||||
it. It used to say only the kind, in the same small capitals as every
|
||||
section label below it, which left the panel with no top.
|
||||
*/}
|
||||
<div className="st-panel-head st-inspector-head">
|
||||
<span className="st-what">
|
||||
{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
|
||||
{sel.length > 1 && <em>{sel.length}</em>}
|
||||
</span>
|
||||
<span className="st-head-acts">
|
||||
<button
|
||||
className="st-act"
|
||||
onClick={() => duplicate(spreadIndex, selection)}
|
||||
title={t('journey.studio.duplicate')}
|
||||
aria-label={t('journey.studio.duplicate')}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`st-act ${el.locked ? 'is-on' : ''}`}
|
||||
onClick={() => set({ locked: !el.locked })}
|
||||
title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
|
||||
aria-label={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
|
||||
aria-pressed={el.locked}
|
||||
>
|
||||
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="st-act is-danger"
|
||||
onClick={() => removeElements(spreadIndex, selection)}
|
||||
title={t('journey.studio.delete')}
|
||||
aria-label={t('journey.studio.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{el.kind === 'text' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.text')}>
|
||||
<textarea
|
||||
className="st-input st-textarea"
|
||||
value={el.text}
|
||||
rows={5}
|
||||
onChange={e => live({ text: e.target.value, overridden: true })}
|
||||
onBlur={e => set({ text: e.target.value, overridden: true })}
|
||||
/>
|
||||
{el.binding && !el.overridden && (
|
||||
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p>
|
||||
)}
|
||||
{/*
|
||||
How a point is set. Only a coordinate binding has the question,
|
||||
and answering it re-sets the words on the spot rather than at
|
||||
the next open — switching a notation and seeing nothing happen
|
||||
reads as a broken control. Deliberately not `overridden`:
|
||||
choosing a format is not writing the text.
|
||||
*/}
|
||||
{el.binding?.source === 'entry.location' && el.binding.format && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.coordsMark')}>
|
||||
<Choice
|
||||
value={el.binding.format}
|
||||
options={[
|
||||
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
|
||||
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
|
||||
]}
|
||||
onPick={f => {
|
||||
const entry = source.entries.find(e => e.id === el.binding!.entryId)
|
||||
const text = entry?.lat != null && entry.lng != null
|
||||
? formatBookCoords(entry.lat, entry.lng, f)
|
||||
: el.text
|
||||
set({ binding: { ...el.binding!, format: f }, text })
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.typography')}>
|
||||
{/*
|
||||
Each family set in itself, because a list of names tells you
|
||||
nothing about a typeface — and grouped, because "which serif"
|
||||
is a different question from "serif or sans".
|
||||
*/}
|
||||
<div className="st-fonts">
|
||||
{BOOK_FONT_ORDER.map(id => {
|
||||
const font = BOOK_FONTS[id]
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={`st-font ${el.font === id ? 'is-on' : ''}`}
|
||||
style={{ fontFamily: font.stack }}
|
||||
onClick={() => set({
|
||||
font: id,
|
||||
// A family that does not ship this weight would render a
|
||||
// synthesised bold — a smeared regular in print — so the
|
||||
// weight moves to the nearest one it really has.
|
||||
weight: nearestWeight(id, el.weight) as typeof el.weight,
|
||||
})}
|
||||
title={font.name}
|
||||
>
|
||||
{font.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.weight')}>
|
||||
<Choice
|
||||
value={el.weight}
|
||||
options={([400, 500, 600, 700] as const).map(w => ({
|
||||
value: w,
|
||||
label: String(w),
|
||||
disabled: !hasWeight(el.font, w),
|
||||
title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
|
||||
}))}
|
||||
onPick={weight => set({ weight })}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.align')}>
|
||||
<div className="st-row">
|
||||
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`}
|
||||
onClick={() => set({ align: a })}
|
||||
aria-label={a}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
{/* Italic sits with the alignment because both are things
|
||||
you do to a line rather than to a typeface. It had no
|
||||
control at all before, though the renderer drew it. */}
|
||||
<button
|
||||
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
|
||||
onClick={() => set({ italic: !el.italic })}
|
||||
title={t('journey.studio.italic')}
|
||||
aria-label={t('journey.studio.italic')}
|
||||
aria-pressed={el.italic}
|
||||
>
|
||||
<Italic size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Line>
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={200} step={0.5} unit="pt" onChange={v => set({ size: v })} />
|
||||
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/*
|
||||
Letter spacing was rendered but had no control, so type the
|
||||
layout had tracked out could be edited in every way except the
|
||||
one that made it look like that.
|
||||
*/}
|
||||
<NumField
|
||||
label={t('journey.studio.tracking')}
|
||||
value={el.tracking}
|
||||
min={-0.2}
|
||||
max={1}
|
||||
step={0.01}
|
||||
unit="em"
|
||||
onChange={v => set({ tracking: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.text')} wide>
|
||||
<Swatches value={el.color} onPick={c => set({ color: c })} />
|
||||
</Line>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'photo' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.crop')}>
|
||||
<Line label={t('journey.studio.crop')} wide>
|
||||
<Choice
|
||||
value={el.fit}
|
||||
options={(['cover', 'contain'] as const).map(f => ({
|
||||
value: f, label: t(`journey.studio.fit.${f}`),
|
||||
}))}
|
||||
onPick={fit => set({ fit })}
|
||||
/>
|
||||
</Line>
|
||||
{/*
|
||||
An empty frame has no picture to aim at, and asking for one by
|
||||
id produced a request for /api/photos/null/thumbnail and a
|
||||
broken image under a caption telling you to drag it.
|
||||
*/}
|
||||
{el.photoId != null ? (
|
||||
<FocalPad
|
||||
photoId={el.photoId}
|
||||
x={el.focalX}
|
||||
y={el.focalY}
|
||||
onDrag={(x, y) => live({ focalX: x, focalY: y })}
|
||||
onCommit={(x, y) => set({ focalX: x, focalY: y })}
|
||||
hint={t('journey.studio.focalHint')}
|
||||
/>
|
||||
) : (
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.emptyFrame')}</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.look')}>
|
||||
<Line label={t('journey.studio.look')} wide>
|
||||
<Choice
|
||||
value={el.filter}
|
||||
options={(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => ({
|
||||
value: f, label: t(`journey.studio.filter.${f}`),
|
||||
}))}
|
||||
onPick={filter => set({ filter })}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.frameStyle')} wide>
|
||||
<Choice
|
||||
value={el.frameStyle}
|
||||
options={([
|
||||
['none', 'plainFrame'],
|
||||
['polaroid', 'polaroidFrame'],
|
||||
['white', 'whiteFrame'],
|
||||
['shadow', 'shadowFrame'],
|
||||
['film', 'filmFrame'],
|
||||
['tape', 'tapeFrame'],
|
||||
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
|
||||
onPick={frameStyle => set({ frameStyle })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
{/* A corner radius and a mask are the same idea at different
|
||||
resolutions, so the radius steps aside once a shape is cut.
|
||||
It did not, and edited a property the renderer ignored. */}
|
||||
{(!el.mask || el.mask === 'rect') && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => set({ radius: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.mask')} defaultOpen={false}>
|
||||
<div className="st-mini-shapes">
|
||||
<button
|
||||
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
|
||||
onClick={() => set({ mask: null })}
|
||||
title={t('journey.studio.maskNone')}
|
||||
>
|
||||
<span className="st-mini-none" />
|
||||
</button>
|
||||
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
|
||||
<button
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ mask: sh })}
|
||||
aria-label={sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'shape' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.shape')}>
|
||||
{/* Every shape, not just the two the element started as: swapping
|
||||
a placed shape keeps its position, size and colour, which
|
||||
deleting and re-adding would not. */}
|
||||
<div className="st-mini-shapes">
|
||||
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
|
||||
<button
|
||||
key={sh}
|
||||
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
|
||||
onClick={() => set({ shape: sh })}
|
||||
aria-label={sh}
|
||||
>
|
||||
<ShapeGlyph shape={sh} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/* A photo had a corner radius and a shape did not, although
|
||||
both store the same field and the renderer draws both. */}
|
||||
<NumField
|
||||
label={t('journey.studio.radius')}
|
||||
value={el.radius}
|
||||
min={0}
|
||||
max={60}
|
||||
step={0.5}
|
||||
unit="mm"
|
||||
onChange={v => set({ radius: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
{/*
|
||||
Fill and stroke, both named. The panel used to show one
|
||||
unlabelled swatch row that wrote `fill`, so on an outline-only
|
||||
shape it lit up a colour that was not being drawn and there was
|
||||
no way to reach the line you could actually see.
|
||||
*/}
|
||||
<Line label={t('journey.studio.fill')} wide>
|
||||
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
|
||||
</Line>
|
||||
<Line label={t('journey.studio.stroke')} wide>
|
||||
<Swatches
|
||||
value={el.stroke ?? '#141414'}
|
||||
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.fillOn')}
|
||||
on={el.fill !== null}
|
||||
onToggle={() => set({
|
||||
// Turning the fill off on a shape with no stroke would make
|
||||
// it invisible, so the outline comes on with it.
|
||||
fill: el.fill === null ? '#111111' : null,
|
||||
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
|
||||
})}
|
||||
/>
|
||||
<Switch
|
||||
label={t('journey.studio.gradient')}
|
||||
on={el.gradient !== 'none'}
|
||||
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
|
||||
/>
|
||||
</div>
|
||||
{el.gradient !== 'none' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.gradient')}>
|
||||
<Choice
|
||||
value={el.gradient}
|
||||
options={[
|
||||
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
|
||||
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
|
||||
]}
|
||||
onPick={gradient => set({ gradient })}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
<Choice
|
||||
value={el.strokeStyle}
|
||||
options={([
|
||||
['solid', 'strokeSolid'],
|
||||
['dashed', 'strokeDashed'],
|
||||
['dotted', 'strokeDotted'],
|
||||
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
|
||||
onPick={strokeStyle => set({
|
||||
strokeStyle,
|
||||
// A stroke style with no stroke is invisible, so asking for
|
||||
// dashes turns the outline on rather than doing nothing.
|
||||
...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
|
||||
})}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.strokeWidth')}
|
||||
value={el.strokeWidth}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.1}
|
||||
unit="mm"
|
||||
onChange={v => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{el.kind === 'icon' && (
|
||||
<>
|
||||
<Section label={t('journey.studio.icon')}>
|
||||
{/*
|
||||
A search rather than the whole library: the picker in the panel
|
||||
can afford to grow as it is scrolled, but the inspector is a
|
||||
column beside a page and its job here is swapping one drawing
|
||||
for a near neighbour. Swapping keeps the position, size and
|
||||
colour that deleting and re-placing would lose.
|
||||
*/}
|
||||
<input
|
||||
className="st-input"
|
||||
value={iconQuery}
|
||||
onChange={e => setIconQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchIcons')}
|
||||
aria-label={t('journey.studio.searchIcons')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="st-mini-shapes" style={{ marginTop: 8 }}>
|
||||
{iconChoices.map(name => {
|
||||
const Glyph = iconComponent(name)
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
|
||||
onClick={() => set({ name })}
|
||||
aria-label={name}
|
||||
title={iconLabel(name)}
|
||||
>
|
||||
<Glyph strokeWidth={1.7} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.icon')} wide>
|
||||
<Swatches value={el.color} onPick={c => set({ color: c })} />
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.lineWidth')}
|
||||
value={el.lineWidth}
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.25}
|
||||
onChange={v => set({ lineWidth: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TravelInspector el={el} stats={stats} set={set} t={t} locale={locale} />
|
||||
|
||||
{/*
|
||||
Where it sits, folded away.
|
||||
|
||||
Everything in here can be done by dragging, which is how it is
|
||||
actually done; the numbers are for the one time in ten you need the
|
||||
same margin on two pages. Opacity and rotation live here too — both
|
||||
were painted by the renderer and neither had a control anywhere.
|
||||
*/}
|
||||
{one && (
|
||||
<Section label={t('journey.studio.position')} defaultOpen={false}>
|
||||
<div className="st-grid2">
|
||||
<NumField label="X" value={el.frame.x} unit="mm" onChange={v => set({ frame: { ...el.frame, x: v } })} />
|
||||
<NumField label="Y" value={el.frame.y} unit="mm" onChange={v => set({ frame: { ...el.frame, y: v } })} />
|
||||
<NumField label={t('journey.studio.width')} value={el.frame.w} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, w: v } })} />
|
||||
<NumField label={t('journey.studio.height')} value={el.frame.h} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, h: v } })} />
|
||||
</div>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<NumField
|
||||
label={t('journey.studio.rotation')}
|
||||
value={el.rotation}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={v => set({ rotation: v })}
|
||||
/>
|
||||
<NumField
|
||||
label={t('journey.studio.opacity')}
|
||||
value={Math.round(el.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
unit="%"
|
||||
onChange={v => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
|
||||
<div className="st-row">
|
||||
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
|
||||
<ChevronsUp size={14} />
|
||||
</button>
|
||||
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the picture is anchored inside its frame.
|
||||
*
|
||||
* Two sliders were the first draft and they were the wrong instrument: a focal
|
||||
* point is one position in a plane, not two unrelated numbers, and nobody can
|
||||
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
|
||||
* image answers the question by showing it.
|
||||
*/
|
||||
function FocalPad({
|
||||
photoId, x, y, onDrag, onCommit, hint,
|
||||
}: {
|
||||
photoId: number
|
||||
x: number
|
||||
y: number
|
||||
onDrag: (x: number, y: number) => void
|
||||
onCommit: (x: number, y: number) => void
|
||||
hint: string
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const last = useRef({ x, y })
|
||||
|
||||
const from = (e: React.PointerEvent) => {
|
||||
const r = box.current!.getBoundingClientRect()
|
||||
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
|
||||
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
|
||||
last.current = { x: nx, y: ny }
|
||||
return last.current
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="st-focal-wrap">
|
||||
<div
|
||||
className="st-focal"
|
||||
ref={box}
|
||||
onPointerDown={e => {
|
||||
(e.target as Element).setPointerCapture(e.pointerId)
|
||||
const p = from(e)
|
||||
onDrag(p.x, p.y)
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return
|
||||
const p = from(e)
|
||||
onDrag(p.x, p.y)
|
||||
}}
|
||||
onPointerUp={() => onCommit(last.current.x, last.current.y)}
|
||||
>
|
||||
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
|
||||
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
|
||||
</div>
|
||||
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The bar at the top of a Studio panel: what it is, and how many.
|
||||
*
|
||||
* Its own file so the three panels can share it without any of them having to
|
||||
* import the others — `StudioSidebar` owns the rail, and a panel that reached
|
||||
* back into it for a header would make the sidebar impossible to split.
|
||||
*/
|
||||
export function PanelHead({ label, count }: { label: string; count?: number }) {
|
||||
return (
|
||||
<div className="st-panel-head">
|
||||
<span>{label}</span>
|
||||
{count != null && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{count}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
ArrowLeft, BookOpen, Check, ChevronDown, Download, Maximize2, Minus, Plus,
|
||||
Redo2, Sparkles, Undo2,
|
||||
} from 'lucide-react'
|
||||
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
import { StudioWordmark } from './StudioWordmark'
|
||||
import { SaveIndicator } from './SaveIndicator'
|
||||
import { downloadSpread } from './spreadFile'
|
||||
import { StudioExport } from './StudioExport'
|
||||
import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
*
|
||||
* It is a child route of the journey and portals itself over it, so the journey
|
||||
* stays mounted underneath and shows through the blurred 16px margin. That
|
||||
* margin is the whole point — you are meant to see that you never left.
|
||||
*
|
||||
* `.trek-dash` on the root is not decoration: every colour, radius and shadow in
|
||||
* studio.css resolves against those tokens, so Studio follows the user's theme
|
||||
* and accent without a second palette to keep in sync.
|
||||
*/
|
||||
export default function StudioShell() {
|
||||
const s = useJourneyStudio()
|
||||
const navigate = useNavigate()
|
||||
const [bookView, setBookView] = useState(true)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
if (s.isMobile) {
|
||||
return createPortal(
|
||||
<div className="st-phone trek-dash">
|
||||
<BookOpen size={30} strokeWidth={1.5} style={{ color: 'var(--ink-3)' }} />
|
||||
<div style={{ fontSize: 17, fontWeight: 600, letterSpacing: '-0.02em' }}>
|
||||
{s.t('journey.studio.desktopOnly')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: 'var(--ink-3)', maxWidth: 320, lineHeight: 1.55 }}>
|
||||
{s.t('journey.studio.desktopOnlyHint')}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(s.backTo, { replace: true })}
|
||||
className="st-tool is-primary"
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
{s.t('journey.studio.backToJourney')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
const originStyle = s.origin
|
||||
? ({ '--st-ox': `${s.origin.x}px`, '--st-oy': `${s.origin.y}px` } as React.CSSProperties)
|
||||
: undefined
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className={`st-scrim ${s.closing ? 'st-scrim-exit' : 'st-scrim-enter'}`}
|
||||
onClick={s.close}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className={`st-root trek-dash ${s.closing ? 'st-exit' : 'st-enter'}`}
|
||||
style={originStyle}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={s.t('journey.studio.title')}
|
||||
>
|
||||
<StudioBar s={s} bookView={bookView} setBookView={setBookView} onExport={() => setExporting(true)} />
|
||||
|
||||
<div className="st-body">
|
||||
<StudioSidebar
|
||||
page={s.page}
|
||||
pxPerMm={s.pxPerMm}
|
||||
bookView={bookView}
|
||||
source={s.source}
|
||||
stats={s.stats}
|
||||
path={s.path}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
page={s.page}
|
||||
stats={s.stats}
|
||||
source={s.source}
|
||||
setPageNumbers={s.setPageNumbers}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{exporting && s.doc && (
|
||||
<StudioExport
|
||||
doc={s.doc}
|
||||
title={s.doc.title || s.journey?.title || ''}
|
||||
t={s.t}
|
||||
onClose={() => setExporting(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
type Studio = ReturnType<typeof useJourneyStudio>
|
||||
|
||||
function StudioBar({
|
||||
s, bookView, setBookView, onExport,
|
||||
}: { s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void }) {
|
||||
return (
|
||||
<div className="st-bar">
|
||||
<button onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
|
||||
<ArrowLeft size={16} />
|
||||
{s.coverUrl
|
||||
? <img src={s.coverUrl} alt="" className="st-cover" />
|
||||
: <span className="st-cover" />}
|
||||
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
{/*
|
||||
The lockup, not a name field. The book is the journey's, and it was
|
||||
already titled after it — a second place to type the same name is a
|
||||
field whose only job is to be left alone. What the bar is short of is
|
||||
somewhere to say what this thing *is*.
|
||||
*/}
|
||||
<div className="st-brand">
|
||||
<StudioWordmark style={{ height: 26 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => { void s.saveNow() }}
|
||||
/>
|
||||
|
||||
<div className="st-bar-group">
|
||||
<button
|
||||
className={`st-tool ${bookView ? 'is-on' : ''}`}
|
||||
onClick={() => setBookView(!bookView)}
|
||||
title={s.t('journey.studio.bookView')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<FormatPicker s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<button
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canUndo}
|
||||
onClick={s.undo}
|
||||
title={s.t('journey.studio.undo')}
|
||||
aria-label={s.t('journey.studio.undo')}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canRedo}
|
||||
onClick={s.redo}
|
||||
title={s.t('journey.studio.redo')}
|
||||
aria-label={s.t('journey.studio.redo')}
|
||||
>
|
||||
<Redo2 size={15} />
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<AutoLayoutButton s={s} />
|
||||
<button
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again — this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => { s.relayoutBook(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The format picker.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead — themed,
|
||||
* anchored under the bar, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallow it, or the shell's Escape handler would close Studio itself.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map(id => {
|
||||
const p = PAGE_PRESETS[id]
|
||||
const on = id === s.preset
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => { s.setPreset(id); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom — the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={v => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={v => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into.
|
||||
|
||||
Under the trim size because they answer the same question — how big
|
||||
is this book, really — and because a vendor states all three in one
|
||||
breath. They were fixed at 3 and 5, which is what most photo-book
|
||||
printers ask for and is useless to the one that asks for 5 and 10.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
return (
|
||||
<div className="st-work-wrap">
|
||||
<div className="st-work" ref={s.workRef}>
|
||||
<StudioCanvas
|
||||
spread={s.spread}
|
||||
spreadIndex={s.activeSpread}
|
||||
page={s.page}
|
||||
zoom={s.zoom}
|
||||
pxPerMm={s.pxPerMm}
|
||||
bookView={bookView}
|
||||
dropLabel={s.t('journey.studio.dropPhotoHere')}
|
||||
cursors={s.cursors}
|
||||
onCursor={(x, y) => s.moveCursor(s.activeSpread, x, y)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="st-zoom">
|
||||
<button onClick={() => s.stepZoom(-1)} disabled={!s.canZoomOut} aria-label={s.t('journey.studio.zoomOut')}>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
<span className="st-zoom-value">{s.zoomPercent}%</span>
|
||||
<button onClick={() => s.stepZoom(1)} disabled={!s.canZoomIn} aria-label={s.t('journey.studio.zoomIn')}>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<button onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
{/*
|
||||
The page, as a file.
|
||||
|
||||
Here rather than in a menu because it belongs to the spread on screen
|
||||
— the zoom bar is the only chrome that is about *this* page rather
|
||||
than about the book. What comes out is the design without the
|
||||
photographs, which is the thing worth passing on.
|
||||
*/}
|
||||
<span className="st-zoom-sep" />
|
||||
<button
|
||||
onClick={() => s.spread && downloadSpread(s.spread, s.page, s.journey?.title || 'spread')}
|
||||
disabled={!s.spread}
|
||||
aria-label={s.t('journey.studio.downloadSpread')}
|
||||
title={s.t('journey.studio.downloadSpreadHint')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, Plus, Search, Shapes, Trash2, X } from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText'
|
||||
import { coordValue } from './resolveBindings'
|
||||
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { PanelHead as Head } from './StudioPanelHead'
|
||||
import { StudioElementsPanel } from './StudioElementsPanel'
|
||||
import { StudioTravelPanel } from './StudioTravelPanel'
|
||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
|
||||
|
||||
/**
|
||||
* The left side of Studio: a narrow rail of sections and one wide panel showing
|
||||
* whichever is open.
|
||||
*
|
||||
* The shape is Canva's and it is the right one here for a plain reason — a book
|
||||
* has four completely different kinds of thing you reach for (which page, what
|
||||
* from the journey, what to draw, what arrangement), and putting them behind
|
||||
* tabs in a single 200px column would make each of them cramped. An icon rail
|
||||
* costs 52px and gives the panel the room a photo browser actually needs.
|
||||
*/
|
||||
|
||||
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates'
|
||||
|
||||
export interface JourneySource {
|
||||
entries: {
|
||||
id: number
|
||||
title: string | null
|
||||
story: string | null
|
||||
location: string | null
|
||||
date: string | null
|
||||
/** Where the stop is, when the entry recorded one. Null on a written-only entry. */
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
mood: string | null
|
||||
weather: string | null
|
||||
pros: string[]
|
||||
cons: string[]
|
||||
}[]
|
||||
photos: { photoId: number; caption?: string | null }[]
|
||||
/** photoId to the words of the entry it belongs to, lower-cased. */
|
||||
photoEntries: Record<number, string>
|
||||
}
|
||||
|
||||
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
/**
|
||||
* How wide a page preview may be drawn.
|
||||
*
|
||||
* Page thumbnails and layout cards are drawn at a `scale()` rather than laid
|
||||
* out by CSS — the sheet inside them is sized in millimetres — so they need an
|
||||
* actual pixel width, and it is **measured** rather than derived.
|
||||
*
|
||||
* Deriving it was tried twice and was wrong twice. The panel width, the scroll
|
||||
* padding and the button's own border and padding are all knowable from
|
||||
* studio.css; the scrollbar is not. It appears only once the list is long
|
||||
* enough, takes a width that depends on the platform, and the arithmetic that
|
||||
* ignored it produced a card overhanging its own selection outline. Measuring
|
||||
* the row the cards sit in gets all four contributions at once and cannot drift
|
||||
* when any of them changes.
|
||||
*/
|
||||
const MIN_PREVIEW_W = 120
|
||||
|
||||
/** The button's border and padding, which the row's width still includes. */
|
||||
const THUMB_CHROME = (2 + 3) * 2
|
||||
|
||||
export function StudioSidebar({
|
||||
page, pxPerMm, bookView, source, stats, path, t, locale,
|
||||
}: {
|
||||
page: BookPageSetup
|
||||
pxPerMm: number
|
||||
bookView: boolean
|
||||
source: JourneySource
|
||||
/** What the journey adds up to, for the travel elements. Null while loading or on failure. */
|
||||
stats: JourneyStats | null
|
||||
/** The roads the trip took, for a map that draws them. Empty when it has none. */
|
||||
path: [number, number][][]
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
}) {
|
||||
const [section, setSection] = useState<Section>('pages')
|
||||
|
||||
const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [
|
||||
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
|
||||
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
|
||||
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
|
||||
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
|
||||
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
|
||||
{SECTIONS.map(sec => (
|
||||
<button
|
||||
key={sec.id}
|
||||
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
|
||||
onClick={() => setSection(sec.id)}
|
||||
title={t(sec.labelKey)}
|
||||
aria-label={t(sec.labelKey)}
|
||||
>
|
||||
<sec.icon size={18} strokeWidth={1.7} />
|
||||
<span>{t(sec.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<aside className="st-panel st-side">
|
||||
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
||||
{section === 'content' && <ContentPanel source={source} page={page} t={t} locale={locale} />}
|
||||
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
|
||||
{section === 'travel' && <StudioTravelPanel page={page} stats={stats} path={path} t={t} locale={locale} />}
|
||||
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PagesPanel({
|
||||
page, pxPerMm, bookView, t,
|
||||
}: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) {
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const setActive = useStudioStore(s => s.setActiveSpread)
|
||||
const addSpread = useStudioStore(s => s.addSpread)
|
||||
const insertSpread = useStudioStore(s => s.insertSpread)
|
||||
const duplicateSpread = useStudioStore(s => s.duplicateSpread)
|
||||
const removeSpread = useStudioStore(s => s.removeSpread)
|
||||
const moveSpread = useStudioStore(s => s.moveSpread)
|
||||
const canEditSpread = useStudioStore(s => s.canEditSpread)
|
||||
const spreads = doc?.spreads ?? []
|
||||
const list = useElementSize<HTMLDivElement>()
|
||||
const THUMB_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
|
||||
|
||||
/** Where a new spread would land: after the last inner one. */
|
||||
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [importError, setImportError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.pages')} count={spreads.length} />
|
||||
<div className="st-panel-scroll">
|
||||
<div className="st-thumbs" ref={list.ref}>
|
||||
{spreads.map((sp, i) => {
|
||||
const single = sp.role !== 'inner'
|
||||
const wMm = single ? page.pageWidth : page.pageWidth * 2
|
||||
// The same component at a smaller scale, not a second drawing of the
|
||||
// page — a spread can never look different here than on the sheet.
|
||||
const scale = THUMB_W / (wMm * pxPerMm)
|
||||
const editable = canEditSpread(i)
|
||||
return (
|
||||
<div className="st-thumb-row" key={sp.id}>
|
||||
<button
|
||||
className={`st-thumb ${i === active ? 'is-active' : ''}`}
|
||||
onClick={() => setActive(i)}
|
||||
>
|
||||
<div className="st-thumb-sheet" style={{ width: THUMB_W, height: page.pageHeight * pxPerMm * scale }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0,
|
||||
width: `${wMm}mm`, height: `${page.pageHeight}mm`,
|
||||
transform: `scale(${scale})`, transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<SpreadView spread={sp} page={page} spreadIndex={i} />
|
||||
</div>
|
||||
{bookView && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
||||
</div>
|
||||
<span className="st-thumb-label">
|
||||
{sp.role === 'cover' ? t('journey.studio.cover')
|
||||
: sp.role === 'back' ? t('journey.studio.backCover')
|
||||
: `${i * 2} – ${i * 2 + 1}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
The actions sit on the thumbnail rather than in a menu bar of
|
||||
their own: what you are acting on is the page you are pointing
|
||||
at, and a toolbar somewhere else would need you to select
|
||||
first and act second. The cover and the back cover have no
|
||||
actions — a book has exactly one of each, and moving or
|
||||
deleting them is not a thing anyone means to do.
|
||||
*/}
|
||||
{editable && (
|
||||
<div className="st-thumb-actions">
|
||||
<button
|
||||
onClick={() => moveSpread(i, -1)}
|
||||
disabled={!canEditSpread(i - 1)}
|
||||
title={t('journey.studio.movePageUp')}
|
||||
aria-label={t('journey.studio.movePageUp')}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveSpread(i, 1)}
|
||||
disabled={!canEditSpread(i + 1)}
|
||||
title={t('journey.studio.movePageDown')}
|
||||
aria-label={t('journey.studio.movePageDown')}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => duplicateSpread(i)}
|
||||
title={t('journey.studio.duplicatePage')}
|
||||
aria-label={t('journey.studio.duplicatePage')}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="is-danger"
|
||||
onClick={() => removeSpread(i)}
|
||||
title={t('journey.studio.deletePage')}
|
||||
aria-label={t('journey.studio.deletePage')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Insert between two spreads, where the new one will go — a
|
||||
single "add" at the end cannot express "another page here". */}
|
||||
{editable && (
|
||||
<button
|
||||
className="st-thumb-insert"
|
||||
onClick={() => addSpread(i)}
|
||||
title={t('journey.studio.addPageAfter')}
|
||||
aria-label={t('journey.studio.addPageAfter')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Two ways to get a new page: an empty one, or somebody else's design.
|
||||
Side by side because they answer the same question — the import is
|
||||
not a setting buried in a menu, it is the other half of "add".
|
||||
*/}
|
||||
<div className="st-add-row">
|
||||
<button className="st-add-page" onClick={() => addSpread(lastInner)}>
|
||||
<Plus size={14} />
|
||||
{t('journey.studio.addPage')}
|
||||
</button>
|
||||
<button
|
||||
className="st-add-page is-import"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
title={t('journey.studio.importSpreadHint')}
|
||||
>
|
||||
<FileDown size={14} />
|
||||
{t('journey.studio.importSpread')}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files?.[0]
|
||||
// Cleared straight away so choosing the same file twice in a row
|
||||
// still fires a change event the second time.
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setImportError(null)
|
||||
// Checked before reading rather than after: the point of a limit on
|
||||
// a file someone else wrote is not to parse it in the first place.
|
||||
if (file.size > MAX_SPREAD_FILE_BYTES) { setImportError(t('journey.studio.importSpreadFailed')); return }
|
||||
try {
|
||||
const spread = importSpread(JSON.parse(await file.text()), page)
|
||||
if (!spread) { setImportError(t('journey.studio.importSpreadFailed')); return }
|
||||
insertSpread(lastInner, spread)
|
||||
} catch {
|
||||
setImportError(t('journey.studio.importSpreadFailed'))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{importError && <p className="st-add-error">{importError}</p>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey's own material.
|
||||
*
|
||||
* This is what separates a book maker from a drawing program: the pictures and
|
||||
* the words are already written, and the job is putting them on pages. Clicking
|
||||
* an item drops it on the current spread, centred, at a sensible size.
|
||||
*/
|
||||
function ContentPanel({
|
||||
source, page, t, locale,
|
||||
}: { source: JourneySource; page: BookPageSetup; t: (k: string) => string; locale: string }) {
|
||||
const [tab, setTab] = useState<'photos' | 'text'>('photos')
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
/*
|
||||
* Filtering, not a search index: a journey holds tens or hundreds of items, and
|
||||
* a substring match over what is already in memory answers instantly. A photo
|
||||
* matches on its own caption *and* on the entry it belongs to — most photos
|
||||
* carry no words at all, so matching only captions would make the box look
|
||||
* broken on exactly the journeys that need it.
|
||||
*/
|
||||
const q = query.trim().toLowerCase()
|
||||
const entries = q
|
||||
? source.entries.filter(e =>
|
||||
[e.title, e.story, e.location, ...e.pros, ...e.cons]
|
||||
.some(v => v && v.toLowerCase().includes(q)))
|
||||
: source.entries
|
||||
const photos = q
|
||||
? source.photos.filter(p =>
|
||||
(p.caption && p.caption.toLowerCase().includes(q))
|
||||
|| (source.photoEntries[p.photoId] || '').includes(q))
|
||||
: source.photos
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
const dropPhoto = (photoId: number) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.55
|
||||
addElement(active, {
|
||||
id: uid('p'), kind: 'photo', frame: centre(side, side * 0.75),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
/**
|
||||
* One piece of the entry, dropped on the page and still attached to it.
|
||||
*
|
||||
* `format` is what separates a place name from a set of coordinates: both
|
||||
* read the entry's location, and which of the two gets printed is a property
|
||||
* of the binding rather than a second source — see the contract. `width`
|
||||
* exists because a line of coordinates arriving inside a full-column
|
||||
* selection rectangle looks like a bug; only the short marks pass it.
|
||||
*/
|
||||
const dropText = (
|
||||
value: string,
|
||||
size: number,
|
||||
weight: 400 | 700,
|
||||
entryId: number,
|
||||
kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date',
|
||||
opts: { format?: CoordFormat; width?: number; value?: string } = {},
|
||||
) => {
|
||||
const w = opts.width ?? page.pageWidth - 32
|
||||
addElement(active, {
|
||||
id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10),
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
text: value, font: 'sans', size, weight, italic: false,
|
||||
align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0,
|
||||
color: '#1a1a1a',
|
||||
binding: {
|
||||
source: kind,
|
||||
entryId,
|
||||
...(opts.format ? { format: opts.format } : {}),
|
||||
// The fact behind the words, for the marks whose words are formatted —
|
||||
// see `value` in the contract.
|
||||
...(opts.value ? { value: opts.value } : {}),
|
||||
},
|
||||
overridden: false,
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
const base = {
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* A mood or a weather mark, drawn with the icon the journey page uses.
|
||||
*
|
||||
* `code` holds the key rather than the label, so the element carries what the
|
||||
* entry recorded and the renderer decides how to draw it — which is what lets
|
||||
* the icon and the palette come from one place.
|
||||
*/
|
||||
const dropMark = (variant: 'mood' | 'weather', code: string, label: string) =>
|
||||
addElement(active, {
|
||||
...base, id: uid('bd'), kind: 'badge', autoColor: true,
|
||||
// Set here as well as in the contract: this object is cast, not parsed.
|
||||
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
|
||||
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062),
|
||||
variant, text: label, sub: '', code, style: 'plain',
|
||||
} as BookElement)
|
||||
|
||||
/** Both columns of an entry's pros and cons, as one element. */
|
||||
const dropProsCons = (pros: string[], cons: string[]) => {
|
||||
const rows = Math.max(pros.length, cons.length, 1)
|
||||
addElement(active, {
|
||||
...base, id: uid('li'), kind: 'list',
|
||||
// Matches what ListView needs for `rows` lines at its largest line size,
|
||||
// so the element arrives filled rather than with a third of it empty.
|
||||
frame: centre(page.pageWidth * 0.72, Math.min(page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
|
||||
items: [
|
||||
...pros.map(text => ({ text, tone: 'pro' as const })),
|
||||
...cons.map(text => ({ text, tone: 'con' as const })),
|
||||
],
|
||||
layout: 'columns', showMarks: true,
|
||||
proLabel: t('journey.editor.pros'),
|
||||
conLabel: t('journey.editor.cons'),
|
||||
} as BookElement)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.content')} />
|
||||
|
||||
<div className="st-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('journey.studio.searchContent')}
|
||||
aria-label={t('journey.studio.searchContent')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-tabs">
|
||||
<button className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
|
||||
{t('journey.studio.photos')} <em>{photos.length}</em>
|
||||
</button>
|
||||
<button className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
|
||||
{t('journey.studio.entries')} <em>{entries.length}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{tab === 'photos' ? (
|
||||
<div className="st-photo-grid">
|
||||
{photos.map(p => (
|
||||
<button
|
||||
key={p.photoId}
|
||||
className="st-photo-cell"
|
||||
onClick={() => dropPhoto(p.photoId)}
|
||||
title={t('journey.studio.addToPage')}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
// HTML5 drag is the right tool for exactly this shape of
|
||||
// interaction — one item, from a list, onto a target — and it
|
||||
// gives us the thumbnail as the drag image for free. The
|
||||
// canvas uses pointer events instead, because free transform
|
||||
// needs a live position that a drop event cannot provide.
|
||||
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
{!photos.length && (
|
||||
<p className="st-hint">{t(q ? 'journey.studio.noMatches' : 'journey.studio.noPhotos')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-entries">
|
||||
{entries.map(e => (
|
||||
<div key={e.id} className="st-entry">
|
||||
<div className="st-entry-head">{e.title || e.location || t('journey.studio.untitled')}</div>
|
||||
{(e.date || e.location) && (
|
||||
<div className="st-entry-meta">
|
||||
{/* Two facts, two badges. Joined by a dot they read as one
|
||||
string and the eye has to parse where the date ends. The
|
||||
date follows the app's language, like every other date in
|
||||
TREK — see utils/formatters.ts. */}
|
||||
{e.date && <span className="st-badge">{formatDate(e.date, locale) ?? e.date}</span>}
|
||||
{e.location && <span className="st-badge is-quiet">{e.location}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="st-row" style={{ marginTop: 7 }}>
|
||||
{e.title && (
|
||||
<button className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
|
||||
{t('journey.studio.addTitle')}
|
||||
</button>
|
||||
)}
|
||||
{e.story && (
|
||||
<button className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
|
||||
{t('journey.studio.addStory')}
|
||||
</button>
|
||||
)}
|
||||
{e.location && (
|
||||
<button className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
|
||||
{t('journey.studio.addPlace')}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
The day and the point. Both are set the way the rest of the
|
||||
book sets them — the long date, coordinates in degrees — so a
|
||||
mark placed here and one the auto layout placed read alike.
|
||||
*/}
|
||||
{e.date && (
|
||||
<button
|
||||
className="st-chip"
|
||||
onClick={() => dropText(
|
||||
formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
|
||||
{ width: page.pageWidth * 0.36, value: e.date! },
|
||||
)}
|
||||
>
|
||||
{t('journey.studio.dateMark')}
|
||||
</button>
|
||||
)}
|
||||
{e.lat != null && e.lng != null && (
|
||||
<button
|
||||
className="st-chip"
|
||||
onClick={() => dropText(
|
||||
formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
|
||||
{ format: 'dms', width: page.pageWidth * 0.36, value: coordValue(e.lat!, e.lng!) },
|
||||
)}
|
||||
>
|
||||
{t('journey.studio.coordsMark')}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
What the entry recorded besides its story. Each chip shows
|
||||
the value rather than the field name — "Sunny" tells you
|
||||
what you are about to place; "Weather" does not.
|
||||
*/}
|
||||
{e.mood && MOOD_CONFIG[e.mood] && (
|
||||
<button
|
||||
className="st-chip"
|
||||
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
|
||||
>
|
||||
{t(MOOD_CONFIG[e.mood].label)}
|
||||
</button>
|
||||
)}
|
||||
{e.weather && WEATHER_CONFIG[e.weather] && (
|
||||
<button
|
||||
className="st-chip"
|
||||
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
|
||||
>
|
||||
{t(WEATHER_CONFIG[e.weather].label)}
|
||||
</button>
|
||||
)}
|
||||
{(e.pros.length > 0 || e.cons.length > 0) && (
|
||||
<button className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
|
||||
{t('journey.studio.addProsCons')}
|
||||
<em>{e.pros.length + e.cons.length}</em>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Layouts for the current spread.
|
||||
*
|
||||
* Each card draws the arrangement itself rather than naming it — "hero + story"
|
||||
* means nothing until you have seen it, and a diagram is faster to read than a
|
||||
* label anyway.
|
||||
*/
|
||||
function TemplatesPanel({
|
||||
page, pxPerMm, t, onOpenContent,
|
||||
}: { page: BookPageSetup; pxPerMm: number; t: (k: string) => string; onOpenContent: () => void }) {
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const spread = doc?.spreads[active]
|
||||
|
||||
const list = useElementSize<HTMLDivElement>()
|
||||
const CARD_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
|
||||
const doc2 = doc
|
||||
const cardIsSingle = (doc2?.spreads[active]?.role ?? 'inner') !== 'inner'
|
||||
// A cover card is one page wide, not two — scaling it as a spread would draw
|
||||
// the layout at half size in the left half of the card.
|
||||
const scale = useMemo(
|
||||
() => CARD_W / (page.pageWidth * (cardIsSingle ? 1 : 2) * pxPerMm),
|
||||
[CARD_W, page.pageWidth, pxPerMm, cardIsSingle],
|
||||
)
|
||||
|
||||
if (!spread) return null
|
||||
const single = spread.role !== 'inner'
|
||||
/*
|
||||
* The cover has its own set. It used to have none — the panel said covers
|
||||
* are designed by hand, which was true and unhelpful: the cover is the page
|
||||
* people care most about and the one they had no starting point for.
|
||||
*/
|
||||
const templates = single ? COVER_TEMPLATES : TEMPLATES
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head label={t('journey.studio.templates')} count={templates.length} />
|
||||
<div className="st-panel-scroll">
|
||||
{(
|
||||
<div className="st-thumbs" ref={list.ref}>
|
||||
{templates.map(tpl => {
|
||||
const slots = tpl.build(page)
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
className="st-thumb"
|
||||
onClick={() => {
|
||||
let empties = 0
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => {
|
||||
if (i !== active) return sp
|
||||
const next = applyTemplate(sp, tpl, page)
|
||||
empties = next.elements.filter(e => e.kind === 'photo' && e.photoId == null).length
|
||||
return next
|
||||
}),
|
||||
}))
|
||||
// The layout left frames waiting for a picture — so put the
|
||||
// pictures within reach instead of making the user go and
|
||||
// find the panel that holds them.
|
||||
if (empties > 0) onOpenContent()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="st-thumb-sheet"
|
||||
style={{ width: CARD_W, height: page.pageHeight * pxPerMm * scale }}
|
||||
>
|
||||
{slots.map((slot, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`st-tpl-slot is-${slot.kind}`}
|
||||
style={{
|
||||
left: slot.frame.x * pxPerMm * scale,
|
||||
top: slot.frame.y * pxPerMm * scale,
|
||||
width: slot.frame.w * pxPerMm * scale,
|
||||
height: Math.max(2, slot.frame.h * pxPerMm * scale),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!single && (
|
||||
<span
|
||||
className="st-tpl-fold"
|
||||
style={{ left: page.pageWidth * pxPerMm * scale }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The colour picker, shared by everything in the inspector that has a colour.
|
||||
*
|
||||
* Its own file because both the plain elements and the travel elements need it,
|
||||
* and the second copy of a swatch row is how two parts of one panel end up
|
||||
* offering different palettes.
|
||||
*
|
||||
* The palette is print ink rather than app chrome — it does not follow the
|
||||
* user's theme, because a book is printed once and read on paper, where an
|
||||
* accent chosen for a dark interface means nothing.
|
||||
*/
|
||||
|
||||
const SWATCHES = [
|
||||
'#111111', '#ffffff', '#8a8578',
|
||||
'#c2410c', '#b45309', '#0f766e',
|
||||
'#1e3a8a', '#9f1239', '#4c1d95',
|
||||
]
|
||||
|
||||
export function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }) {
|
||||
return (
|
||||
<div className="st-swatches">
|
||||
{SWATCHES.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
|
||||
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
|
||||
onClick={() => onPick(c)}
|
||||
aria-label={c}
|
||||
/>
|
||||
))}
|
||||
{/* Anything at all, for a book that has to match a cover or a brand. */}
|
||||
<label className="st-swatch st-swatch-custom">
|
||||
<input type="color" value={value} onChange={e => onPick(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
import { useState } from 'react'
|
||||
import type {
|
||||
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
|
||||
BookMetric, BookStatsElement, JourneyStats,
|
||||
} from '@trek/shared'
|
||||
import { BOOK_METRICS } from '@trek/shared'
|
||||
import { isStale, refreshPatch } from './travelRefresh'
|
||||
import { Swatches } from './StudioSwatches'
|
||||
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
|
||||
import { sourceIdOf, useMapSources } from './mapSources'
|
||||
import { printDpi, tileView } from './mapTiles'
|
||||
import { routeFor } from './travelRefresh'
|
||||
import { Choice, Line, Picks, Section, Switch } from './StudioControls'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
|
||||
/**
|
||||
* Properties of the travel elements.
|
||||
*
|
||||
* Its own file because these four are a different kind of thing from a photo or
|
||||
* a paragraph: what you adjust is *what the element says*, not how a box looks.
|
||||
* Keeping them here leaves StudioInspector.tsx about the elements that have
|
||||
* been there since the beginning.
|
||||
*/
|
||||
|
||||
export interface TravelInspectorProps {
|
||||
el: BookElement
|
||||
/** Live figures, for the refresh button. Null while loading or on failure. */
|
||||
stats: JourneyStats | null
|
||||
set: (patch: Partial<BookElement>) => void
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
el: T
|
||||
set: (patch: Partial<BookElement>) => void
|
||||
t: (k: string) => string
|
||||
}
|
||||
|
||||
export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
|
||||
if (
|
||||
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
|
||||
&& el.kind !== 'badge' && el.kind !== 'list'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stale = isStale(el, stats)
|
||||
|
||||
return (
|
||||
<>
|
||||
{stale && (
|
||||
<div className="st-section">
|
||||
<p className="st-stale">{t('journey.studio.staleHint')}</p>
|
||||
<button
|
||||
className="st-chip"
|
||||
onClick={() => {
|
||||
if (!stats) return
|
||||
const patch = refreshPatch(el, stats)
|
||||
if (patch) set(patch)
|
||||
}}
|
||||
>
|
||||
{t('journey.studio.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} />}
|
||||
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />}
|
||||
{el.kind === 'list' && <ListProps el={el} set={set} t={t} />}
|
||||
|
||||
{/*
|
||||
* Type, for all five alike.
|
||||
*
|
||||
* These elements set their own words — a figure over its caption, a
|
||||
* country's name over its outline — and until now the only thing you
|
||||
* could change about that was the size. A book set in a serif with its
|
||||
* stats block in the default sans is a book with one element in the
|
||||
* wrong typeface and no way to fix it.
|
||||
*/}
|
||||
<Section label={t('journey.studio.typography')} defaultOpen={false}>
|
||||
<div className="st-fonts">
|
||||
{BOOK_FONT_ORDER.map(id => {
|
||||
const font = BOOK_FONTS[id]
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={`st-font ${el.font === id ? 'is-on' : ''}`}
|
||||
style={{ fontFamily: font.stack }}
|
||||
onClick={() => set({
|
||||
font: id,
|
||||
// A family that does not ship this weight renders a
|
||||
// synthesised bold — a smeared regular in print — so the
|
||||
// weight moves to the nearest one it really has.
|
||||
weight: nearestWeight(id, el.weight) as typeof el.weight,
|
||||
} as Partial<BookElement>)}
|
||||
title={font.name}
|
||||
>
|
||||
{font.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.weight')}>
|
||||
<Choice
|
||||
value={el.weight}
|
||||
options={([400, 500, 600, 700] as const).map(w => ({
|
||||
value: w,
|
||||
label: String(w),
|
||||
disabled: !hasWeight(el.font, w),
|
||||
title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
|
||||
}))}
|
||||
onPick={weight => set({ weight } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.textScale')}>
|
||||
<Choice
|
||||
value={el.textScale}
|
||||
options={([0.7, 0.85, 1, 1.2, 1.5] as const).map(scale => ({
|
||||
value: scale, label: `${scale}×`,
|
||||
}))}
|
||||
onPick={textScale => set({ textScale } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/*
|
||||
* Colour, for all five alike, and in one group.
|
||||
*
|
||||
* These were two sections called Accent and Colour, one under the other,
|
||||
* each ten unlabelled swatches wrapping onto two rows: twenty squares and
|
||||
* no way to tell which of them painted what. The role now sits beside the
|
||||
* row and says it — the accent is the mark's own colour, the ink is what
|
||||
* is written on it.
|
||||
*
|
||||
* They ship with one warm accent so a book made without touching anything
|
||||
* still reads as designed. That is a starting point, not a house style: a
|
||||
* book printed to match a cover, or one that simply should not be orange,
|
||||
* needs both of these.
|
||||
*/}
|
||||
<Section label={t('journey.studio.colour')}>
|
||||
<Line label={t('journey.studio.accent')} wide>
|
||||
<Swatches value={el.accent} onPick={c => set({ accent: c } as Partial<BookElement>)} />
|
||||
</Line>
|
||||
<Line label={t('journey.studio.text')} wide>
|
||||
{/*
|
||||
Picking a colour is what turns automatic off, so the swatch does
|
||||
both and the switch below is the way back rather than the way in.
|
||||
Only marks have it; nothing else here has a fill of its own for the
|
||||
words to answer to.
|
||||
*/}
|
||||
<Swatches
|
||||
value={el.color}
|
||||
onPick={c => set(
|
||||
(el.kind === 'badge' ? { color: c, autoColor: false } : { color: c }) as Partial<BookElement>,
|
||||
)}
|
||||
/>
|
||||
</Line>
|
||||
{el.kind === 'badge' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.autoColour')}
|
||||
on={el.autoColor}
|
||||
onToggle={() => set({ autoColor: !el.autoColor } as Partial<BookElement>)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ListProps({ el, set, t }: Props<BookListElement>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.addProsCons')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={[
|
||||
{ value: 'columns' as const, label: t('journey.studio.layoutGrid') },
|
||||
{ value: 'stacked' as const, label: t('journey.studio.layoutColumn') },
|
||||
]}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
<div className="st-grid2" style={{ marginTop: 10 }}>
|
||||
<label className="st-field">
|
||||
<span>{t('journey.editor.pros')}</span>
|
||||
<input
|
||||
className="st-input"
|
||||
value={el.proLabel}
|
||||
onChange={e => set({ proLabel: e.target.value } as Partial<BookElement>)}
|
||||
/>
|
||||
</label>
|
||||
<label className="st-field">
|
||||
<span>{t('journey.editor.cons')}</span>
|
||||
<input
|
||||
className="st-input"
|
||||
value={el.conLabel}
|
||||
onChange={e => set({ conLabel: e.target.value } as Partial<BookElement>)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.showMarks')}
|
||||
on={el.showMarks}
|
||||
onToggle={() => set({ showMarks: !el.showMarks } as Partial<BookElement>)}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: JourneyStats | null }) {
|
||||
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
|
||||
const [roadDone, setRoadDone] = useState(0)
|
||||
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
|
||||
|
||||
/*
|
||||
* One leg at a time, paced, and written into the document when it is done
|
||||
* rather than leg by leg: a hundred commits would be a hundred undo steps and
|
||||
* a hundred autosaves for one action.
|
||||
*/
|
||||
const loadRoads = async () => {
|
||||
setRoadState('busy')
|
||||
setRoadDone(0)
|
||||
try {
|
||||
const roads = await fetchRoads(
|
||||
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ onProgress: done => setRoadDone(done) },
|
||||
)
|
||||
set({ roads } as Partial<BookElement>)
|
||||
} finally {
|
||||
setRoadState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl })
|
||||
/** Only trips that put stops on the route: a map of the others is a blank. */
|
||||
const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
|
||||
/*
|
||||
* The grid this element will really fetch, asked for here so the panel can
|
||||
* say what it will print at. Same call the renderer makes, same answer.
|
||||
*/
|
||||
const view = el.source === 'tiles' && el.tileUrl
|
||||
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom)
|
||||
: null
|
||||
const dpi = view ? printDpi(view, el.tileUrl) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
Which trip, when the journey is made of more than one.
|
||||
|
||||
A journey of two trips drawn as one route puts a line from the last
|
||||
stop of the first to the first stop of the second: usually the longest
|
||||
leg on the page, and one nobody travelled. Switching here re-cuts the
|
||||
route on the spot rather than at the next refresh, because a control
|
||||
that changes nothing visible reads as broken.
|
||||
*/}
|
||||
{tripsWithRoute.length > 1 && (
|
||||
<Section label={t('journey.studio.mapScope')}>
|
||||
<Choice
|
||||
value={el.tripId ?? 0}
|
||||
options={[
|
||||
{ value: 0, label: t('journey.studio.mapWholeJourney') },
|
||||
...tripsWithRoute.map(trip => ({
|
||||
value: trip.id,
|
||||
label: trip.title || t('journey.studio.untitled'),
|
||||
})),
|
||||
]}
|
||||
onPick={id => {
|
||||
if (!stats) return
|
||||
const tripId = id === 0 ? null : id
|
||||
const route = routeFor(stats, tripId)
|
||||
set({
|
||||
tripId,
|
||||
countries: tripId == null
|
||||
? stats.countries.map(c => c.code)
|
||||
: [...new Set(route.map(p => p.country).filter((c): c is string => !!c))],
|
||||
points: route.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
|
||||
} as Partial<BookElement>)
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Where the picture comes from and what it looks like: one question
|
||||
with two halves, which is one group rather than two. */}
|
||||
<Section label={t('journey.studio.routeMap')}>
|
||||
<Line label={t('journey.studio.mapSource')} wide>
|
||||
<Choice
|
||||
value={sourceIdOf(el, sources)}
|
||||
options={sources.map(src => ({ value: src.id, label: t(src.labelKey) }))}
|
||||
onPick={id => {
|
||||
const src = sources.find(x => x.id === id)
|
||||
if (!src) return
|
||||
set({
|
||||
source: src.source,
|
||||
tileUrl: src.url,
|
||||
// The credit travels with the source. Switching to imagery and
|
||||
// leaving the previous attribution would print the wrong one.
|
||||
attribution: src.attribution,
|
||||
} as Partial<BookElement>)
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<Choice
|
||||
value={el.style}
|
||||
options={(['minimal', 'outline', 'paper', 'dark'] as const).map(style => ({
|
||||
value: style, label: t(`journey.studio.mapStyle.${style}`),
|
||||
}))}
|
||||
onPick={style => set({ style } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
{el.source !== 'vector' && (
|
||||
<>
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>
|
||||
{t(SOURCE_HINT[sourceIdOf(el, sources)] ?? 'journey.studio.mapSourceHint')}
|
||||
</p>
|
||||
{/*
|
||||
What this map will actually print at, at the size it is now.
|
||||
A number rather than a warning icon: 300 means nothing to argue
|
||||
with, and somebody who wants a soft map on purpose can have one.
|
||||
*/}
|
||||
{dpi != null && (
|
||||
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
|
||||
{t('journey.studio.mapPrintDpi')} {dpi} dpi
|
||||
{dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/*
|
||||
── What the map shows ─────────────────────────────────────────────
|
||||
|
||||
Three questions, and they are not the same question: how close the view
|
||||
is, how much air is left around it, and what shape the picture is cut
|
||||
to. The first two used to be decided for you, and the decision was
|
||||
wrong often enough to be the loudest complaint about this element: a
|
||||
trip that stayed inside one city was drawn as the whole country with
|
||||
two dots in the middle of it.
|
||||
*/}
|
||||
<Section label={t('journey.studio.mapFraming')}>
|
||||
<Line label={t('journey.studio.mapFit')}>
|
||||
<Choice
|
||||
value={el.fitToCountries ? 'country' : 'stops'}
|
||||
options={[
|
||||
{ value: 'stops' as const, label: t('journey.studio.mapFitStops') },
|
||||
{ value: 'country' as const, label: t('journey.studio.mapFitCountry') },
|
||||
]}
|
||||
onPick={v => set({ fitToCountries: v === 'country' } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
|
||||
{/*
|
||||
Room around what is drawn, as a share of it: the same setting has to
|
||||
look right around a walk across a city and a drive across a
|
||||
continent, and millimetres cannot do that.
|
||||
*/}
|
||||
<Line label={t('journey.studio.mapPadding')}>
|
||||
<Choice
|
||||
value={([0.04, 0.18, 0.5, 1.2] as const).find(v => Math.abs(el.fitPadding - v) < 0.001) ?? 0.18}
|
||||
options={[
|
||||
{ value: 0.04, label: t('journey.studio.mapPadTight') },
|
||||
{ value: 0.18, label: t('journey.studio.mapPadNormal') },
|
||||
{ value: 0.5, label: t('journey.studio.mapPadWide') },
|
||||
{ value: 1.2, label: t('journey.studio.mapPadFar') },
|
||||
]}
|
||||
onPick={fitPadding => set({ fitPadding } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
|
||||
{/*
|
||||
And the shape. A rectangle is a map in a box, which is what a full
|
||||
page wants; cut to the coastline it stops being a figure and becomes
|
||||
an illustration that can sit next to anything.
|
||||
*/}
|
||||
<Line label={t('journey.studio.mapShape')}>
|
||||
<Choice
|
||||
value={el.clip}
|
||||
options={[
|
||||
{ value: 'rect' as const, label: t('journey.studio.mapClipRect') },
|
||||
{
|
||||
value: 'country' as const,
|
||||
label: t('journey.studio.mapClipCountry'),
|
||||
disabled: el.countries.length === 0,
|
||||
title: el.countries.length === 0 ? t('journey.studio.mapClipNeedsCountry') : undefined,
|
||||
},
|
||||
]}
|
||||
onPick={clip => set({ clip } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
</Section>
|
||||
|
||||
{el.source === 'tiles' && (
|
||||
<Section label={t('journey.studio.mapZoom')} defaultOpen={false}>
|
||||
<Choice
|
||||
value={el.zoom ?? -1}
|
||||
options={[
|
||||
{ value: -1, label: t('journey.studio.mapZoomAuto') },
|
||||
...[3, 5, 7, 9, 11, 13, 15, 17].map(z => ({ value: z, label: String(z) })),
|
||||
]}
|
||||
onPick={z => set({ zoom: z === -1 ? null : z } as Partial<BookElement>)}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/*
|
||||
How the line itself is drawn, and whether it follows real roads.
|
||||
|
||||
Open by default, unlike the groups below it. It was folded, on the
|
||||
reasoning that a look is chosen once per book — but a control nobody can
|
||||
see is a control nobody has, and the first thing anyone wants from a map
|
||||
of a journey is the line across it.
|
||||
*/}
|
||||
<Section label={t('journey.studio.routeLook')}>
|
||||
<Line label={t('journey.studio.routeStyle')} wide>
|
||||
<Choice
|
||||
value={el.routeStyle}
|
||||
options={[
|
||||
{ value: 'plain' as const, label: t('journey.studio.routePlain') },
|
||||
{ value: 'drawn' as const, label: t('journey.studio.routeDrawn') },
|
||||
]}
|
||||
onPick={routeStyle => set({ routeStyle } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{/*
|
||||
The roads, fetched once and kept.
|
||||
|
||||
Not a switch, because it is not free: it asks a public routing service
|
||||
one question per leg, from this browser, and the answer is then frozen
|
||||
into the document so the book prints the same line offline and next
|
||||
year. A switch would imply it could simply be turned back on later,
|
||||
on a page that is being exported, which is exactly when it cannot.
|
||||
*/}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/*
|
||||
Which way the line goes between two stops.
|
||||
|
||||
A choice rather than a button, because that is what it is from where
|
||||
the user sits: the roads, or the way a map has always drawn a leg it
|
||||
does not know. That the roads have to be fetched once and are then
|
||||
kept in the book is an implementation detail, and the only place it
|
||||
shows is the counter while it works.
|
||||
*/}
|
||||
<Line label={t('journey.studio.roads')} wide>
|
||||
{roadState === 'busy' ? (
|
||||
<div className="st-row">
|
||||
<span className="st-chip is-on">
|
||||
{t('journey.studio.roadsBusy')} {roadDone}/{Math.max(1, el.points.length - 1)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Choice
|
||||
value={hasRoads ? 'roads' : 'direct'}
|
||||
options={[
|
||||
{ value: 'roads' as const, label: t('journey.studio.roadsFollow') },
|
||||
{ value: 'direct' as const, label: t('journey.studio.roadsDirect') },
|
||||
]}
|
||||
onPick={v => {
|
||||
if (v === 'direct') {
|
||||
set({ roads: [] } as Partial<BookElement>)
|
||||
} else {
|
||||
void loadRoads()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
{hasRoads && roadState !== 'busy' && (
|
||||
<div className="st-row" style={{ marginTop: 8 }}>
|
||||
<button className="st-chip" onClick={() => void loadRoads()}>
|
||||
{t('journey.studio.roadsAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="st-hint" style={{ paddingTop: 6 }}>
|
||||
{t(hasRoads ? 'journey.studio.roadsHave' : 'journey.studio.roadsHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{el.routeStyle === 'drawn' && (
|
||||
<>
|
||||
<Line label={t('journey.studio.routeArc')} wide>
|
||||
<Choice
|
||||
value={el.routeArc}
|
||||
options={[
|
||||
{ value: 'straight' as const, label: t('journey.studio.routeStraight') },
|
||||
{ value: 'bow' as const, label: t('journey.studio.routeBow') },
|
||||
]}
|
||||
onPick={routeArc => set({ routeArc } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{el.routeArc === 'bow' && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.routeDashArcs')}
|
||||
on={el.routeDash === 'arcs'}
|
||||
onToggle={() => set({
|
||||
routeDash: el.routeDash === 'arcs' ? 'solid' : 'arcs',
|
||||
} as Partial<BookElement>)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Line label={t('journey.studio.mapStops')} wide>
|
||||
<Choice
|
||||
value={el.pinStyle}
|
||||
options={[
|
||||
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
|
||||
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
|
||||
]}
|
||||
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
</div>
|
||||
{el.pinStyle === 'photo' && !el.points.some(p => p.photoId != null) && (
|
||||
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.pinPhotoNone')}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Four things that are either drawn or not. They used to be the same
|
||||
pill as the style choice above, so a layer looked like an option. */}
|
||||
<Section label={t('journey.studio.mapLayers')}>
|
||||
{el.source !== 'tiles' && el.source !== 'static' && (
|
||||
<Switch label={t('journey.studio.showLand')} on={el.showLand} onToggle={() => set({ showLand: !el.showLand } as Partial<BookElement>)} />
|
||||
)}
|
||||
<Switch label={t('journey.studio.showRoute')} on={el.showRoute} onToggle={() => set({ showRoute: !el.showRoute } as Partial<BookElement>)} />
|
||||
<Switch label={t('journey.studio.showPins')} on={el.showPins} onToggle={() => set({ showPins: !el.showPins } as Partial<BookElement>)} />
|
||||
<Switch label={t('journey.studio.showLabels')} on={el.showLabels} onToggle={() => set({ showLabels: !el.showLabels } as Partial<BookElement>)} />
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsProps({ el, set, t }: Props<BookStatsElement>) {
|
||||
const toggleMetric = (m: BookMetric) => {
|
||||
const next = el.metrics.includes(m) ? el.metrics.filter(x => x !== m) : [...el.metrics, m]
|
||||
// An empty panel would be a blank rectangle nobody could get back out of,
|
||||
// so the last figure cannot be switched off.
|
||||
if (!next.length) return
|
||||
set({ metrics: next } as Partial<BookElement>)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
Which figures, out of seven. A set rather than a row of choices, and it
|
||||
looks like one now: the picked ones carry a tick instead of being the
|
||||
same filled pill the layout below uses for "this one".
|
||||
*/}
|
||||
<Section label={t('journey.studio.metrics')}>
|
||||
<Picks
|
||||
value={el.metrics}
|
||||
options={BOOK_METRICS.map(m => ({ value: m, label: t(`journey.studio.metric.${m}`) }))}
|
||||
onToggle={toggleMetric}
|
||||
/>
|
||||
</Section>
|
||||
<Section label={t('journey.studio.layout')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['grid', 'row', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.units')}>
|
||||
<Choice
|
||||
value={el.units}
|
||||
options={[
|
||||
{ value: 'metric' as const, label: 'km' },
|
||||
{ value: 'imperial' as const, label: 'mi' },
|
||||
]}
|
||||
onPick={units => set({ units } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{/* Its label used to be "Elements", which is the name of a panel on
|
||||
the other side of the screen and not what this draws. */}
|
||||
<Switch
|
||||
label={t('journey.studio.showIcons')}
|
||||
on={el.showIcons}
|
||||
onToggle={() => set({ showIcons: !el.showIcons } as Partial<BookElement>)}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
|
||||
return (
|
||||
<Section label={t('journey.studio.countries')}>
|
||||
<Line label={t('journey.studio.layout')}>
|
||||
<Choice
|
||||
value={el.layout}
|
||||
options={(['list', 'grid', 'column'] as const).map(layout => ({
|
||||
value: layout,
|
||||
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
|
||||
}))}
|
||||
onPick={layout => set({ layout } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{/* These three read as raw English out of the code before: `left`,
|
||||
`center`, `right`, straight from the value. */}
|
||||
<Line label={t('journey.studio.align')}>
|
||||
<Choice
|
||||
value={el.align}
|
||||
options={(['left', 'center', 'right'] as const).map(align => ({
|
||||
value: align, label: t(`journey.studio.align.${align}`),
|
||||
}))}
|
||||
onPick={align => set({ align } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch label={t('journey.studio.showOutline')} on={el.showOutline} onToggle={() => set({ showOutline: !el.showOutline } as Partial<BookElement>)} />
|
||||
<Switch label={t('journey.studio.showFlag')} on={el.showFlag} onToggle={() => set({ showFlag: !el.showFlag } as Partial<BookElement>)} />
|
||||
<Switch label={t('journey.studio.showName')} on={el.showName} onToggle={() => set({ showName: !el.showName } as Partial<BookElement>)} />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The names themselves, one per line.
|
||||
|
||||
They arrive resolved from the journey and are usually right, but "usually"
|
||||
is not a thing you can print: a country whose English name nobody uses,
|
||||
a territory the geocoder names differently from the way the trip talked
|
||||
about it, a line that wants to read "Scotland" rather than "United
|
||||
Kingdom". A textarea rather than a field per country because the list is
|
||||
two names on one page and twenty on another, and lines are how anyone
|
||||
already edits a list.
|
||||
|
||||
Editing only the names is deliberate: the codes stay as they were, so the
|
||||
outlines and flags keep matching the places actually visited.
|
||||
*/}
|
||||
{el.showName && (
|
||||
<label className="st-field" style={{ marginTop: 10 }}>
|
||||
<span>{t('journey.studio.countryNames')}</span>
|
||||
<textarea
|
||||
className="st-input st-textarea"
|
||||
rows={Math.min(8, Math.max(2, el.codes.length))}
|
||||
value={el.names.join('\n')}
|
||||
onChange={e => {
|
||||
const lines = e.target.value.split('\n')
|
||||
// One name per country, in the order they were visited: a removed
|
||||
// line must not shift every name after it onto the wrong outline.
|
||||
set({
|
||||
names: el.codes.map((code, i) => (lines[i] ?? el.names[i] ?? code).slice(0, 80)),
|
||||
} as Partial<BookElement>)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
/** What each imagery source is for, said once where it is chosen. */
|
||||
const SOURCE_HINT: Record<string, string> = {
|
||||
relief: 'journey.studio.mapSourceReliefHint',
|
||||
satellite: 'journey.studio.mapSourceSatelliteHint',
|
||||
}
|
||||
|
||||
/** The variants that draw something as well as saying something. */
|
||||
const ICON_VARIANTS = ['flag', 'country', 'mood', 'weather']
|
||||
|
||||
function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
|
||||
const showIcon = el.showIcon !== false
|
||||
const showLabel = el.showLabel !== false
|
||||
const parts = (icon: boolean, label: boolean) =>
|
||||
set({ showIcon: icon, showLabel: label } as Partial<BookElement>)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section label={t('journey.studio.marks')}>
|
||||
<Line label={t('journey.studio.text')}>
|
||||
<input
|
||||
className="st-input"
|
||||
value={el.text}
|
||||
onChange={e => set({ text: e.target.value } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
<Line label={t('journey.studio.styleCaption')}>
|
||||
<input
|
||||
className="st-input"
|
||||
value={el.sub}
|
||||
onChange={e => set({ sub: e.target.value } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
{/* These four printed the raw value — `plain`, `chip` — in every one of
|
||||
the twenty-three languages. */}
|
||||
<Line label={t('journey.studio.style')} wide>
|
||||
<Choice
|
||||
value={el.style}
|
||||
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
|
||||
value: style, label: t(`journey.studio.markStyle.${style}`),
|
||||
}))}
|
||||
onPick={style => set({ style } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
</Section>
|
||||
|
||||
{/*
|
||||
The icon, for the marks that have one.
|
||||
|
||||
Three combinations rather than two switches: a mark with neither its
|
||||
picture nor its words is an empty box, and offering it as a state people
|
||||
can reach means offering them a way to lose an element on the page.
|
||||
*/}
|
||||
{ICON_VARIANTS.includes(el.variant) && (
|
||||
<Section label={t('journey.studio.icon')}>
|
||||
<Line label={t('journey.studio.shows')} wide>
|
||||
<Choice
|
||||
value={showIcon && showLabel ? 'both' : showIcon ? 'icon' : 'label'}
|
||||
options={[
|
||||
{ value: 'both' as const, label: t('journey.studio.iconAndLabel') },
|
||||
{ value: 'icon' as const, label: t('journey.studio.iconOnly') },
|
||||
{ value: 'label' as const, label: t('journey.studio.labelOnly') },
|
||||
]}
|
||||
onPick={v => parts(v !== 'label', v !== 'icon')}
|
||||
/>
|
||||
</Line>
|
||||
{showIcon && (
|
||||
<>
|
||||
{/*
|
||||
A mood's icon carries the journal's own colour and the others
|
||||
carry the element's accent — which is right until it is not, and
|
||||
until now there was no way past it: the swatch below is the only
|
||||
thing that reaches the icon, because lucide takes its colour as a
|
||||
prop rather than inheriting it.
|
||||
*/}
|
||||
<Line label={t('journey.studio.colour')} wide>
|
||||
<Swatches
|
||||
value={el.iconColor}
|
||||
onPick={c => set({ iconColor: c, autoIconColor: false } as Partial<BookElement>)}
|
||||
/>
|
||||
</Line>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Switch
|
||||
label={t('journey.studio.autoColour')}
|
||||
on={el.autoIconColor !== false}
|
||||
onToggle={() => set({ autoIconColor: el.autoIconColor === false } as Partial<BookElement>)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { BOOK_METRICS } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { TravelPreview } from './TravelPreview'
|
||||
import { formatBookCoords } from './entryText'
|
||||
import { routeFor } from './travelRefresh'
|
||||
import { useMapSources } from './mapSources'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
|
||||
/**
|
||||
* The journey's own figures, as things you can put on a page.
|
||||
*
|
||||
* This is the panel that makes Studio a *travel* book designer rather than a
|
||||
* layout tool that happens to be full of holiday photographs. The route, the
|
||||
* distance, the countries and the dates are already in TREK; the work is
|
||||
* offering them as objects rather than as numbers someone has to retype.
|
||||
*
|
||||
* ── Why every tile is a real preview ──────────────────────────────────────
|
||||
*
|
||||
* These elements differ from one another by what they *say*, not by their
|
||||
* shape. Four map styles are four maps; three summary layouts are the same six
|
||||
* numbers arranged three ways. A named row with the value beside it — which is
|
||||
* what this panel was first — tells you none of that: you cannot tell the dark
|
||||
* map from the paper one, or a row of figures from a grid of them, until you
|
||||
* have placed one and undone it.
|
||||
*
|
||||
* So each tile builds the element it would place and renders it through the
|
||||
* page's own renderer, with this journey's real numbers in it. You are picking
|
||||
* from the things themselves.
|
||||
*
|
||||
* Every button resolves its values *now* and writes them into the element, so
|
||||
* what lands on the page is finished rather than a placeholder that needs the
|
||||
* server to mean anything. The reasoning is in TravelElements.tsx, and the
|
||||
* short version is that the print renderer must never depend on a fetch.
|
||||
*/
|
||||
|
||||
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
/*
|
||||
* How many cards share a row.
|
||||
*
|
||||
* A stats panel is the width of a spread; shown two-up its figures scale down
|
||||
* past reading, so it gets the row to itself. Maps, country lists and marks are
|
||||
* compact enough that two-up still tells them apart, and two-up is what makes
|
||||
* the section scannable. The tile measures its own width — see TravelPreview.
|
||||
*/
|
||||
|
||||
/** Kilometres, grouped for the reader's locale. */
|
||||
function formatDistance(metres: number, locale: string): string {
|
||||
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(metres / 1000)} km`
|
||||
}
|
||||
|
||||
function Card({ label, onClick, children, recommended = false, title }: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
/**
|
||||
* Marked as the one to reach for, with a ring rather than with words.
|
||||
*
|
||||
* The label sits in a grid cell about eighty pixels wide, where "Satellite ·
|
||||
* recommended" is two lines of ellipsis. A ring says the same thing in no
|
||||
* space at all, and the tooltip carries the word for anyone who wonders why
|
||||
* this card looks different.
|
||||
*/
|
||||
recommended?: boolean
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
|
||||
onClick={onClick}
|
||||
title={title ? `${label} — ${title}` : label}
|
||||
>
|
||||
{children}
|
||||
<span className="st-travel-card-label">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioTravelPanel({
|
||||
page, stats, path, t, locale,
|
||||
}: {
|
||||
page: BookPageSetup
|
||||
stats: JourneyStats | null
|
||||
/** The travelled way, already thinned. Empty when the trip has no geometry. */
|
||||
path: [number, number][][]
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
}) {
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const updateElement = useStudioStore(s => s.updateElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
const single = !!spread && spread.role !== 'inner'
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
}
|
||||
|
||||
const mapSide = Math.min(page.pageWidth, page.pageHeight) * 0.72
|
||||
|
||||
/*
|
||||
* The imagery this instance can reach, so the relief card below places a map
|
||||
* that already has its template and its credit rather than an empty one the
|
||||
* user then has to point at a source.
|
||||
*
|
||||
* Asked for above the empty-state return, and not beside the card that reads
|
||||
* it: a journey whose figures are still loading renders this panel once with
|
||||
* no stats at all, and a hook that only runs on the second render is a hook
|
||||
* count that changes between them.
|
||||
*/
|
||||
const sources = useMapSources(
|
||||
{ x: 0, y: 0, w: mapSide, h: mapSide * 0.78 },
|
||||
(stats?.points ?? []).map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
)
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.travel')} />
|
||||
<div className="st-panel-scroll">
|
||||
<p className="st-hint">{t('journey.studio.travelEmpty')}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const base = {
|
||||
rotation: 0, opacity: 1, locked: false,
|
||||
font: 'sans' as const, color: '#1a1a1a', accent: '#111111', textScale: 1, weight: 700 as const,
|
||||
stale: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Country names in the reader's language, not the server's.
|
||||
*
|
||||
* The API answers in English, because it has no idea who will read the book.
|
||||
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
|
||||
* a German book says "Island" and an English one says "Iceland" — from one set
|
||||
* of codes, with no translation table to keep in step.
|
||||
*/
|
||||
const countryName = (code: string): string => {
|
||||
try {
|
||||
const display = new Intl.DisplayNames([locale], { type: 'region' })
|
||||
return display.of(code.toUpperCase()) || code.toUpperCase()
|
||||
} catch {
|
||||
return stats.countries.find(c => c.code === code)?.name || code.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
const values: Record<string, number> = {
|
||||
distance: stats.distance,
|
||||
days: stats.days,
|
||||
steps: stats.steps,
|
||||
photos: stats.photos,
|
||||
countries: stats.countries.length,
|
||||
places: stats.places,
|
||||
furthest: stats.furthest,
|
||||
}
|
||||
|
||||
/*
|
||||
* Each builder returns the finished element. The tile renders it and the
|
||||
* click places it, so the preview and the result are the same object — there
|
||||
* is no second description of what a card means.
|
||||
*/
|
||||
/** The linked trips that actually put stops on the route. */
|
||||
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
|
||||
|
||||
/* Satellite first: the recommendation should not be the second thing read. */
|
||||
const imagery = sources
|
||||
.filter(s => s.id === 'relief' || s.id === 'satellite')
|
||||
.sort((a, b) => (a.id === 'satellite' ? -1 : b.id === 'satellite' ? 1 : 0))
|
||||
|
||||
/**
|
||||
* One country, as the entry a list would have made of it.
|
||||
*
|
||||
* The list and the grid are gone: they were compositions somebody else made,
|
||||
* deciding the order, the spacing and the type size, and the only way to
|
||||
* change any of that was to not use them. What is left is the same entry —
|
||||
* name over silhouette, set the same way — as an element you place one at a
|
||||
* time. Three of them down a page is the list, with the spacing yours; one of
|
||||
* them at 80mm is a chapter opener, which the list could never be.
|
||||
*
|
||||
* The name is editable afterwards, so numbering them "1. Germany",
|
||||
* "2. Netherlands" is typing rather than a feature.
|
||||
*/
|
||||
const countryEl = (code: string): BookElement => ({
|
||||
...base, id: uid('co'), kind: 'countries',
|
||||
frame: centre(page.pageWidth * 0.34, 34),
|
||||
codes: [code], names: [countryName(code)],
|
||||
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
|
||||
} as BookElement)
|
||||
|
||||
const mapEl = (
|
||||
style: 'minimal' | 'outline' | 'dark' | 'paper',
|
||||
source: 'vector' | 'tiles' | 'static' = 'vector',
|
||||
url = '',
|
||||
attribution = '',
|
||||
clip: 'rect' | 'country' = 'rect',
|
||||
/** Null is the whole journey; a trip id is a map of that trip alone. */
|
||||
tripId: number | null = null,
|
||||
): BookElement => {
|
||||
const route = routeFor(stats, tripId)
|
||||
return {
|
||||
...base, id: uid('mp'), kind: 'map',
|
||||
frame: centre(mapSide, mapSide * 0.78),
|
||||
style, source, tileUrl: url, attribution, zoom: null, clip, tripId,
|
||||
/*
|
||||
* White over a photograph, ink on paper. The accent IS the line, so this is
|
||||
* how a satellite map arrives drawn the way the reference draws it, while
|
||||
* the colour stays something the user can change afterwards.
|
||||
*/
|
||||
accent: source === 'tiles' || source === 'static' ? '#ffffff' : base.accent,
|
||||
showLand: true, showRoute: true, showPins: true, showLabels: false,
|
||||
/*
|
||||
* A map placed today gets the drawn treatment; the contract defaults to the
|
||||
* plain line so that every book made before this existed opens unchanged.
|
||||
* Set explicitly rather than inherited because this object is cast, not
|
||||
* parsed.
|
||||
*/
|
||||
routeStyle: 'drawn', routeArc: 'bow', routeDash: 'arcs', pinStyle: 'photo',
|
||||
countries: tripId == null
|
||||
? stats.countries.map(c => c.code)
|
||||
: [...new Set(route.map(p => p.country).filter((c): c is string => !!c))],
|
||||
points: route.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
|
||||
/*
|
||||
* Frozen into the element, like every other travel figure: a page that
|
||||
* fetches its own route at print time is a page that changes when someone
|
||||
* edits the trip, and prints empty when the export runs signed out.
|
||||
*/
|
||||
path,
|
||||
// Empty until somebody asks for the roads; see roadRoute.ts.
|
||||
roads: [],
|
||||
fitPadding: 0.5,
|
||||
fitToCountries: true,
|
||||
} as BookElement
|
||||
}
|
||||
|
||||
/*
|
||||
* A mark is placed at the size that suits what it holds.
|
||||
*
|
||||
* One size for all six put a flag and a country name into a box built for a
|
||||
* line of coordinates, which is how a small mark ends up floating inside a
|
||||
* large selection rectangle. Fractions of the page, so the proportions hold
|
||||
* on any format.
|
||||
*/
|
||||
const MARK_SIZE: Record<string, [number, number]> = {
|
||||
flag: [0.25, 0.062],
|
||||
date: [0.16, 0.14],
|
||||
day: [0.17, 0.062],
|
||||
coords: [0.36, 0.05],
|
||||
country: [0.22, 0.078],
|
||||
distance: [0.26, 0.066],
|
||||
}
|
||||
|
||||
const badgeEl = (
|
||||
variant: 'flag' | 'date' | 'day' | 'coords' | 'country' | 'distance',
|
||||
text: string,
|
||||
sub: string,
|
||||
code: string | null,
|
||||
style: 'plain' | 'chip' | 'outline' | 'stacked' = 'plain',
|
||||
): BookElement => {
|
||||
const [fw, fh] = MARK_SIZE[variant] ?? [0.24, 0.08]
|
||||
return {
|
||||
...base, id: uid('bd'), kind: 'badge', autoColor: true,
|
||||
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
|
||||
frame: centre(page.pageWidth * fw, page.pageHeight * fh),
|
||||
variant, text, sub, code, style,
|
||||
} as BookElement
|
||||
}
|
||||
|
||||
/**
|
||||
* One figure on its own.
|
||||
*
|
||||
* A summary panel is a composition; a single figure is a mark you drop next
|
||||
* to a photograph. Both are the same element with a different metric list,
|
||||
* and offering only the composition meant anyone wanting "14 DAYS" beside a
|
||||
* picture had to place all four and delete three.
|
||||
*/
|
||||
const singleEl = (metric: BookMetric): BookElement => ({
|
||||
...base, id: uid('st'), kind: 'stats',
|
||||
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.13),
|
||||
metrics: [metric], layout: 'grid', showIcons: true, units: 'metric', values,
|
||||
} as BookElement)
|
||||
|
||||
/** Place a copy — the previewed element keeps its own id for React. */
|
||||
const place = (el: BookElement) => {
|
||||
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
|
||||
addElement(active, placed)
|
||||
|
||||
/*
|
||||
* A map arrives following the roads.
|
||||
*
|
||||
* Asked for after the element is on the page rather than before, so the map
|
||||
* appears at once and the line firms up a moment later: a panel that sat
|
||||
* there for fifteen seconds after a click would read as broken, and the
|
||||
* straight line it starts with is the one it would have had anyway.
|
||||
*
|
||||
* Legs long enough to have been flights are never sent (see roadRoute.ts),
|
||||
* the router's own cache answers a second placement of the same journey for
|
||||
* free, and a failure leaves the leg exactly as it was.
|
||||
*/
|
||||
if (placed.kind === 'map' && placed.points.length > 1) {
|
||||
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })))
|
||||
.then(roads => {
|
||||
if (roads.some(r => r && r.length > 1)) {
|
||||
updateElement(active, placed.id, { roads } as Partial<BookElement>)
|
||||
}
|
||||
})
|
||||
.catch(() => { /* No roads is a state the map already draws. */ })
|
||||
}
|
||||
}
|
||||
|
||||
const first = stats.points[0] ?? null
|
||||
const firstCountry = stats.countries[0] ?? null
|
||||
const startDay = stats.start ? new Date(`${stats.start}T00:00:00`) : null
|
||||
|
||||
const marks: { el: BookElement; label: string }[] = []
|
||||
if (startDay) {
|
||||
marks.push({
|
||||
el: badgeEl('date', String(startDay.getDate()),
|
||||
startDay.toLocaleDateString(locale, { month: 'long' }).toUpperCase(), null, 'stacked'),
|
||||
label: t('journey.studio.dateMark'),
|
||||
})
|
||||
}
|
||||
marks.push({
|
||||
el: badgeEl('day', `${t('journey.studio.dayWord')} 1`, '', null, 'chip'),
|
||||
label: t('journey.studio.dayMark'),
|
||||
})
|
||||
if (first) {
|
||||
marks.push({
|
||||
el: badgeEl('coords', formatBookCoords(first.lat, first.lng), first.label, null),
|
||||
label: t('journey.studio.coordsMark'),
|
||||
})
|
||||
}
|
||||
if (firstCountry) {
|
||||
marks.push({
|
||||
el: badgeEl('flag', '', countryName(firstCountry.code), firstCountry.code),
|
||||
label: t('journey.studio.flagMark'),
|
||||
})
|
||||
}
|
||||
marks.push({
|
||||
el: badgeEl('distance', formatDistance(stats.distance, locale), t('journey.studio.metric.distance'), null, 'outline'),
|
||||
label: t('journey.studio.distanceMark'),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHead label={t('journey.studio.travel')} />
|
||||
<div className="st-panel-scroll">
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.singleFigures')}</div>
|
||||
<div className="st-travel-grid">
|
||||
{BOOK_METRICS.map(metric => {
|
||||
const el = singleEl(metric)
|
||||
return (
|
||||
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={62} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
|
||||
{stats.points.length ? (
|
||||
<>
|
||||
<div className="st-travel-grid">
|
||||
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
|
||||
const el = mapEl(style)
|
||||
return (
|
||||
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
{/*
|
||||
The two imagery maps, as cards rather than as settings.
|
||||
|
||||
They were reachable only by placing an outline map and then
|
||||
changing its source in the inspector, which is a thing nobody
|
||||
finds. They are different pictures, not variants of one: relief
|
||||
is land shaded in its own colours against a dark sea, drawn at
|
||||
a scale that suits a continent, and satellite is the ground
|
||||
itself, which is what a page about one country wants.
|
||||
*/}
|
||||
{imagery.map(src => (
|
||||
<Card
|
||||
key={src.id}
|
||||
/*
|
||||
* Satellite is marked rather than merely listed. The three
|
||||
* imagery options are not equals: relief stops at about six
|
||||
* hundred metres to the pixel, which is a continent's worth
|
||||
* of detail and a smudge at country size, and the outlines
|
||||
* are a diagram. For the page most people are making —
|
||||
* a route across one or two countries — this is the one
|
||||
* that prints, and saying so beats letting them find out
|
||||
* after the book is bound.
|
||||
*/
|
||||
label={t(src.labelKey)}
|
||||
recommended={src.id === 'satellite'}
|
||||
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
|
||||
onClick={() => place(mapEl('minimal', 'tiles', src.url, src.attribution))}
|
||||
>
|
||||
<TravelPreview
|
||||
el={mapEl('minimal', 'tiles', src.url, src.attribution)}
|
||||
minHeight={62}
|
||||
maxHeight={80}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A map per trip, for a journey made of more than one.
|
||||
|
||||
Two trips printed as a single route draw a line from the last
|
||||
stop of the first to the first stop of the second, which is
|
||||
usually the longest leg on the page and one nobody travelled.
|
||||
Offered rather than imposed: some journeys really are one route
|
||||
across several trips, and those still get the map above.
|
||||
|
||||
Only trips that have stops on the route are listed — a linked
|
||||
trip nobody wrote about would place an empty frame.
|
||||
*/}
|
||||
{tripsWithRoute.length > 1 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="st-section-label">{t('journey.studio.mapPerTrip')}</div>
|
||||
<div className="st-travel-grid">
|
||||
{tripsWithRoute.map(trip => {
|
||||
const el = mapEl('minimal', 'vector', '', '', 'rect', trip.id)
|
||||
return (
|
||||
<Card
|
||||
key={trip.id}
|
||||
label={trip.title || t('journey.studio.untitled')}
|
||||
onClick={() => place(el)}
|
||||
>
|
||||
<TravelPreview el={el} minHeight={62} maxHeight={80} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noRoute')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.countries')}</div>
|
||||
{stats.countries.length ? (
|
||||
<>
|
||||
{/*
|
||||
One country per element, rather than a block listing them all.
|
||||
|
||||
A list of countries is a composition somebody else made: it
|
||||
decides the order, the spacing and the type size, and the only
|
||||
way to change any of it is to not use it. Placed one at a time
|
||||
they are ordinary elements — numbered, moved, set at different
|
||||
sizes, put on different pages — which is what a book of a trip
|
||||
through several countries actually wants.
|
||||
*/}
|
||||
<div className="st-travel-grid">
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = countryEl(c.code)
|
||||
return (
|
||||
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={44} maxHeight={64} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
And the same country as a mark: the outline beside the name
|
||||
rather than under it, at the size of a line of type. It belongs
|
||||
next to a date or a coordinate, not on a page of its own.
|
||||
*/}
|
||||
<div className="st-travel-grid" style={{ marginTop: 6 }}>
|
||||
{stats.countries.slice(0, 12).map(c => {
|
||||
const el = badgeEl('country', countryName(c.code), '', c.code)
|
||||
return (
|
||||
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={30} maxHeight={44} />
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="st-hint">{t('journey.studio.noCountries')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.marks')}</div>
|
||||
<div className="st-travel-grid">
|
||||
{marks.map(({ el, label }) => (
|
||||
<Card key={el.id} label={label} onClick={() => place(el)}>
|
||||
<TravelPreview el={el} minHeight={34} maxHeight={52} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import type { BookElement } from '@trek/shared'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import { ElementView } from './SpreadView'
|
||||
|
||||
/**
|
||||
* A travel element, drawn small.
|
||||
*
|
||||
* The same component the page uses, at a smaller scale — not a second drawing
|
||||
* of it. This is the trick the page thumbnails already use, and it is worth
|
||||
* more here than anywhere else in Studio: these elements differ from each other
|
||||
* by *what they say*, not by their shape, so a generic icon of a map tells you
|
||||
* nothing about which of the four map styles you are about to place. Rendered
|
||||
* for real, with this journey's own figures, the tile answers the question the
|
||||
* label was only gesturing at.
|
||||
*
|
||||
* It also cannot drift. If the map gains a layer or the summary changes how it
|
||||
* sets a figure, the preview shows that on the same commit.
|
||||
*
|
||||
* ── Why the width is measured ─────────────────────────────────────────────
|
||||
*
|
||||
* The tile is a grid cell, so its width is the panel's minus padding, gaps, the
|
||||
* card's own border and whatever the scrollbar takes. Passing a guess in was
|
||||
* tried and was two pixels out, which `overflow: hidden` turned into a clipped
|
||||
* right edge. Measuring costs one ResizeObserver and cannot be wrong.
|
||||
*/
|
||||
|
||||
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
|
||||
const PX_PER_MM = 96 / 25.4
|
||||
|
||||
export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
|
||||
el: BookElement
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
}) {
|
||||
const box = useElementSize<HTMLSpanElement>()
|
||||
const width = box.width
|
||||
|
||||
/*
|
||||
* The tile takes the element's proportions rather than a fixed box.
|
||||
*
|
||||
* A stats panel is three times wider than it is tall and a country list is
|
||||
* taller than it is wide. Fitted into one shape, the wide ones would sit in a
|
||||
* thin band with empty tile above and below — and, worse, would be scaled
|
||||
* down to fit a height they did not need, which is what made the figures
|
||||
* unreadable. Following the element uses the full width for everything and
|
||||
* costs only a slightly uneven grid.
|
||||
*/
|
||||
const drawnW = el.frame.w * PX_PER_MM
|
||||
const drawnH = el.frame.h * PX_PER_MM
|
||||
const height = Math.round(Math.min(maxHeight, Math.max(minHeight, (width * drawnH) / drawnW || minHeight)))
|
||||
const scale = width > 0 ? Math.min(width / drawnW, height / drawnH) : 0
|
||||
|
||||
return (
|
||||
<span className="st-travel-preview" style={{ height }} ref={box.ref} aria-hidden>
|
||||
{scale > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: (width - drawnW * scale) / 2,
|
||||
top: (height - drawnH * scale) / 2,
|
||||
width: `${el.frame.w}mm`,
|
||||
height: `${el.frame.h}mm`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
{/* Drawn at the origin: the tile is the frame, so the element's own
|
||||
position on the spread is not part of what is being shown. */}
|
||||
<ElementView el={{ ...el, frame: { ...el.frame, x: 0, y: 0 } } as BookElement} big={false} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import { PAGE_MAX_MM, PAGE_MIN_MM } from './pagePresets'
|
||||
|
||||
/**
|
||||
* One side of the trim size.
|
||||
*
|
||||
* ── Why it holds a draft ─────────────────────────────────────────────────
|
||||
*
|
||||
* The value goes through `clampPageSize` on the way in, and it used to do so on
|
||||
* every keystroke: typing 500 meant the field saw 5, clamped it to the minimum
|
||||
* and wrote that back under the cursor, so the number could not be typed at
|
||||
* all — only nudged with the arrows. So while a number is being typed the field
|
||||
* keeps what was typed, and the document only hears about values it can use.
|
||||
*
|
||||
* The arrows still work as before, because a value they produce is already in
|
||||
* range and is committed the moment it appears. Leaving the field commits what
|
||||
* is there, clamped — someone who typed 5 and clicked away meant the smallest
|
||||
* book, not the one they had.
|
||||
*/
|
||||
export function TrimField({
|
||||
label, value, onCommit, min = PAGE_MIN_MM, max = PAGE_MAX_MM, step = 1,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onCommit: (mm: number) => void
|
||||
/**
|
||||
* The range this particular measurement lives in.
|
||||
*
|
||||
* Defaulted to the page's own, which is what the field was built for. The
|
||||
* bleed and the safe margin are millimetres of the same kind and want the
|
||||
* same field, but a floor of 60mm would make a 3mm bleed untypeable and its
|
||||
* arrows useless — the range is a property of the measurement, not of the
|
||||
* control.
|
||||
*/
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const shown = draft ?? String(Math.round(value * 10) / 10)
|
||||
|
||||
const settle = () => {
|
||||
if (draft === null) return
|
||||
const n = Number(draft)
|
||||
if (draft.trim() !== '' && Number.isFinite(n)) onCommit(n)
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={shown}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
setDraft(raw)
|
||||
// Anything already usable goes straight through, so the arrows and a
|
||||
// fully typed number both take effect without waiting for a blur.
|
||||
const n = Number(raw)
|
||||
if (raw !== '' && Number.isFinite(n) && n >= min && n <= max) {
|
||||
setDraft(null)
|
||||
onCommit(n)
|
||||
}
|
||||
}}
|
||||
onBlur={settle}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur()
|
||||
if (e.key === 'Escape') setDraft(null)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { BookElement, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
|
||||
import type { SpreadTemplate } from './bookTemplates.data'
|
||||
import { formatBookCoords, formatBookDate } from './entryText'
|
||||
import { coordValue } from './resolveBindings'
|
||||
|
||||
/**
|
||||
* Laying an entry out on a template somebody drew.
|
||||
*
|
||||
* ── Why this exists ──────────────────────────────────────────────────────
|
||||
*
|
||||
* The layouts written as code in autoLayout.ts are what a function can reason
|
||||
* its way to: a picture here, words there, margins that add up. What they are
|
||||
* not is *designed* — a panel bled off one edge, a shape running off the
|
||||
* corner, a strip of pictures at two sizes with the coordinates set under them.
|
||||
* Those decisions come from looking at a page, and the editor is where looking
|
||||
* at a page happens.
|
||||
*
|
||||
* So the templates are drawn in Studio and read back out (see
|
||||
* bookTemplates.data.ts). This is the other half: taking one of those spreads
|
||||
* and putting an entry into it.
|
||||
*
|
||||
* ── What it fills, and what it leaves alone ──────────────────────────────
|
||||
*
|
||||
* Only what the entry can answer for:
|
||||
*
|
||||
* - text carrying a `binding` — the title, the story, a caption
|
||||
* - empty photo frames, taking the entry's photographs in order
|
||||
* - the day, coordinate, date and country marks
|
||||
* - the stats and countries elements, from the journey's figures
|
||||
*
|
||||
* Everything else is the design and is copied as drawn. A panel stays where it
|
||||
* was put; a decorative shape keeps its colour. That division is the whole
|
||||
* contract between the person who draws a template and the code that uses it.
|
||||
*
|
||||
* ── Sizes ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* The template's numbers are fractions of the page it was drawn on, so they are
|
||||
* multiplied back up here. A template built on a 210mm square lays out on an A4
|
||||
* landscape book without being redrawn.
|
||||
*/
|
||||
|
||||
export interface TemplateEntry {
|
||||
id: number
|
||||
title: string | null
|
||||
story: string | null
|
||||
location: string | null
|
||||
date: string | null
|
||||
photos: { photoId: number; width: number | null; height: number | null; caption?: string | null }[]
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
country?: string | null
|
||||
dayNumber?: number | null
|
||||
dayCount?: number | null
|
||||
}
|
||||
|
||||
export interface TemplateContext {
|
||||
page: BookPageSetup
|
||||
locale: string
|
||||
stats: JourneyStats | null
|
||||
/** Already translated, because this module has no translation context. */
|
||||
dayLabel: string
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
const uid = (p: string) => `${p}-${(seq++).toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`
|
||||
|
||||
/** How well a template suits an entry: more is better, -1 means unusable. */
|
||||
export function templateFit(template: SpreadTemplate, entry: TemplateEntry): number {
|
||||
const frames = template.elements.filter(e => e.kind === 'photo').length
|
||||
const photos = entry.photos.length
|
||||
const wantsStory = template.elements.some(e => e.kind === 'text' && e.binding?.source === 'entry.story')
|
||||
const hasStory = !!(entry.story || '').trim()
|
||||
|
||||
/*
|
||||
* A template is a template *for an entry* when it uses one.
|
||||
*
|
||||
* The distinction has to come out of the drawing rather than from a label
|
||||
* somebody remembers to set: a summary spread is all figures and country
|
||||
* outlines and reads perfectly well on its own, and dropping a day's
|
||||
* photographs into it would be nonsense. Bindings are how a page says it
|
||||
* expects an entry.
|
||||
*/
|
||||
const usesEntry = template.elements.some(
|
||||
e => ('binding' in e && e.binding) || (e.kind === 'photo' && e.photoId == null),
|
||||
)
|
||||
if (!usesEntry) return -1
|
||||
|
||||
/*
|
||||
* A template with three frames and an entry with one picture leaves two
|
||||
* placeholders on a finished page, which is worse than a simpler layout.
|
||||
* More pictures than frames is fine — the extra ones are parked.
|
||||
*/
|
||||
if (frames > photos + 1) return -1
|
||||
if (wantsStory && !hasStory) return -1
|
||||
|
||||
// Closest match wins: a two-frame template for two pictures beats a one-frame
|
||||
// one, and both beat a template that would leave a hole.
|
||||
return 100 - Math.abs(frames - photos) * 10 - (wantsStory === hasStory ? 0 : 5)
|
||||
}
|
||||
|
||||
/** Fill a template with an entry, and hand back the spread it makes. */
|
||||
export function applyTemplate(
|
||||
template: SpreadTemplate,
|
||||
entry: TemplateEntry,
|
||||
ctx: TemplateContext,
|
||||
): BookSpread {
|
||||
const { page } = ctx
|
||||
const PW = page.pageWidth
|
||||
const PH = page.pageHeight
|
||||
|
||||
let nextPhoto = 0
|
||||
const story = (entry.story || '').trim()
|
||||
const heading = entry.title || entry.location || ''
|
||||
|
||||
const elements = template.elements.map(el => {
|
||||
const out = {
|
||||
...el,
|
||||
id: uid(el.kind[0]),
|
||||
frame: {
|
||||
x: round2(el.frame.x * PW),
|
||||
y: round2(el.frame.y * PH),
|
||||
w: round2(el.frame.w * PW),
|
||||
h: round2(el.frame.h * PH),
|
||||
},
|
||||
} as BookElement
|
||||
|
||||
// Sizes came out as fractions of the page height, so they go back up.
|
||||
if ('size' in out && typeof out.size === 'number') out.size = round2(out.size * PH)
|
||||
if ('radius' in out && typeof out.radius === 'number') out.radius = round2(out.radius * PW)
|
||||
if ('strokeWidth' in out && typeof out.strokeWidth === 'number') {
|
||||
out.strokeWidth = round2(out.strokeWidth * PW)
|
||||
}
|
||||
|
||||
if (out.kind === 'text' && out.binding) {
|
||||
const source = out.binding.source
|
||||
const format = out.binding.format
|
||||
// What the words were made from, where they were made rather than copied.
|
||||
let value: string | undefined
|
||||
if (source === 'entry.title') out.text = heading
|
||||
else if (source === 'entry.story') out.text = story
|
||||
else if (source === 'entry.date') {
|
||||
out.text = formatBookDate(entry.date, ctx.locale)
|
||||
value = entry.date ?? undefined
|
||||
}
|
||||
// The place, as its name or as its point — see the `format` note in the
|
||||
// contract. A stop the entry cannot answer keeps the words the template
|
||||
// was drawn with, the same bargain fillBadge makes below.
|
||||
else if (source === 'entry.location') {
|
||||
if (format && entry.lat != null && entry.lng != null) {
|
||||
out.text = formatBookCoords(entry.lat, entry.lng, format)
|
||||
value = coordValue(entry.lat, entry.lng)
|
||||
} else if (!format && entry.location) {
|
||||
out.text = entry.location
|
||||
}
|
||||
}
|
||||
out.binding = { ...out.binding, entryId: entry.id, ...(value ? { value } : {}) }
|
||||
}
|
||||
|
||||
if (out.kind === 'photo' && out.photoId == null) {
|
||||
const photo = entry.photos[nextPhoto++]
|
||||
if (photo) out.photoId = photo.photoId
|
||||
}
|
||||
|
||||
if (out.kind === 'badge') fillBadge(out, entry, ctx)
|
||||
|
||||
if (out.kind === 'countries' && ctx.stats) {
|
||||
out.codes = ctx.stats.countries.map(c => c.code)
|
||||
out.names = ctx.stats.countries.map(c => c.name)
|
||||
}
|
||||
|
||||
// The figures live in one map on the element, keyed by metric — the same
|
||||
// shape the panels write when a stats element is placed by hand.
|
||||
if (out.kind === 'stats' && ctx.stats) {
|
||||
out.values = {
|
||||
distance: ctx.stats.distance,
|
||||
days: ctx.stats.days,
|
||||
steps: ctx.stats.steps,
|
||||
photos: ctx.stats.photos,
|
||||
places: ctx.stats.places,
|
||||
countries: ctx.stats.countries.length,
|
||||
furthest: ctx.stats.furthest,
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
return {
|
||||
id: uid('sp'),
|
||||
role: 'inner',
|
||||
background: template.background,
|
||||
elements,
|
||||
parked: [],
|
||||
entryId: entry.id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The small marks, filled from the stop.
|
||||
*
|
||||
* A badge whose value the entry cannot answer keeps the one it was drawn with:
|
||||
* a template showing "DAY 1" on an entry with no date is a template that reads
|
||||
* as a placeholder, which is exactly what it is until a date exists.
|
||||
*/
|
||||
function fillBadge(el: BookElement & { kind: 'badge' }, entry: TemplateEntry, ctx: TemplateContext) {
|
||||
if (el.variant === 'day' && entry.dayNumber) {
|
||||
el.text = `${ctx.dayLabel} ${entry.dayNumber}`
|
||||
}
|
||||
if (el.variant === 'coords' && entry.lat != null && entry.lng != null) {
|
||||
el.text = formatBookCoords(entry.lat, entry.lng)
|
||||
}
|
||||
if ((el.variant === 'flag' || el.variant === 'country') && entry.country) {
|
||||
el.code = entry.country
|
||||
el.text = countryName(entry.country, ctx).toUpperCase()
|
||||
}
|
||||
if (el.variant === 'date' && entry.date) {
|
||||
const d = new Date(`${entry.date}T00:00:00`)
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
el.text = String(d.getDate())
|
||||
el.sub = d.toLocaleDateString(ctx.locale, { month: 'long' }).toUpperCase()
|
||||
}
|
||||
}
|
||||
if (el.variant === 'distance' && ctx.stats?.distance) {
|
||||
el.text = `${Math.round(ctx.stats.distance / 1000).toLocaleString('en-US')} km`
|
||||
}
|
||||
}
|
||||
|
||||
function countryName(code: string, ctx: TemplateContext): string {
|
||||
const known = ctx.stats?.countries.find(c => c.code === code)
|
||||
try {
|
||||
return new Intl.DisplayNames([ctx.locale], { type: 'region' }).of(code) ?? known?.name ?? code
|
||||
} catch {
|
||||
return known?.name ?? code
|
||||
}
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user