mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec486214f1 | |||
| d09a09ad52 | |||
| 8b41561db9 | |||
| 73744203fb | |||
| bc385f27dd | |||
| 866b9e94af | |||
| ed7c79bcb7 | |||
| 1e4b618878 | |||
| 982861264e | |||
| 6434fe7a3c | |||
| 70e07f1965 | |||
| 87041c8842 | |||
| edb113f97f | |||
| fcd5f5dbb6 | |||
| 6849742d67 | |||
| 022e088da4 | |||
| 2471fc5aa7 | |||
| fad301ef5e | |||
| 1c83bf78fa | |||
| a6cd4ffdff | |||
| 988d27c39d | |||
| 62b954cffc | |||
| a2fed6afb8 | |||
| 97510e007e | |||
| 36f81934aa | |||
| 303bce431d | |||
| 73811e0bb7 | |||
| 0ca9f7241a | |||
| bab4c7edcd | |||
| 166b0d8ab3 | |||
| a5dbf6934c | |||
| d2bd6e7556 | |||
| 676d086ff2 | |||
| d8793f3d0b | |||
| fcf4c6e736 | |||
| fa40dfba2c | |||
| 2a17227337 | |||
| a5757dd59f | |||
| 2e10263155 | |||
| 7a7c66e801 | |||
| a55728fe46 | |||
| 8bd5e428b4 | |||
| 504b58ecf6 | |||
| 0f05b0342c | |||
| 7eea0e7669 | |||
| 0f88a74976 | |||
| 9ffd5e5ffb | |||
| 00a326029b | |||
| a47b35b965 | |||
| fcfcf2ff17 | |||
| 016567dd9a | |||
| b64dd700a8 | |||
| b0b6f5872f | |||
| e119a2c936 | |||
| 45ae8b5af2 | |||
| e28bd2fc99 | |||
| 1696162d22 | |||
| f375a2f3cc | |||
| 53bd21a7d7 | |||
| 67d8f22622 | |||
| 8433251c86 | |||
| 13449c3338 | |||
| 35c3ab4bd7 | |||
| 7356de5124 | |||
| 8a2cb10f7b | |||
| 0050072b76 | |||
| da43a81d26 | |||
| a955328070 | |||
| 53c05ab33b | |||
| 5160bd23d5 | |||
| 48ff1b3a98 | |||
| cf35dc7afd | |||
| 4b3340546f | |||
| 1d9648267b | |||
| c0ec8e567b | |||
| 2508fd8624 | |||
| de497d15a4 | |||
| 05666295e0 | |||
| 62da83f5f7 | |||
| b333910533 | |||
| 2d1da19152 | |||
| e7ac46c756 | |||
| f1a2174f96 | |||
| 89c33ac3f4 | |||
| aeda658f18 | |||
| 2f3a0219cd | |||
| 41fb5759e2 | |||
| 6a77496438 | |||
| 487f8dd886 | |||
| 09f286fbfe | |||
| 4cc62f4c4e | |||
| cb34c46226 | |||
| e777118272 | |||
| 3f2da7a867 | |||
| 9c3649f011 | |||
| 73fdbdc0c3 | |||
| 0879b5aecc | |||
| 9729b0b575 | |||
| 35acdb8ba1 | |||
| a19fdf6161 | |||
| 813dd7a7eb | |||
| d51b9de2b0 | |||
| 6254510e0f | |||
| c525b2f84c | |||
| b3d30a834e | |||
| 589d3354eb | |||
| 8ce3b24a01 | |||
| 06a1dcd730 | |||
| 4b633d8cd2 | |||
| 728a275fef | |||
| f6befaa6b2 | |||
| fd6d3fddd5 | |||
| 280e55bbc9 | |||
| e84060cdf7 | |||
| fbc85b05a1 | |||
| 01333c6ff3 | |||
| aecef03090 |
@@ -22,7 +22,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
|
||||
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<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.
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 🧭 Planning
|
||||
|
||||
- **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
|
||||
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
|
||||
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
|
||||
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
|
||||
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
|
||||
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
|
||||
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
|
||||
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
#### 🥾 Tours
|
||||
|
||||
- **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
|
||||
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
|
||||
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
|
||||
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
|
||||
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
|
||||
- **Multi-day tours**: one route cut into stages and spread across the trip's days
|
||||
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
|
||||
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
|
||||
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
|
||||
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of nights with a check-in window
|
||||
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
|
||||
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
|
||||
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
|
||||
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
|
||||
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
|
||||
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **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
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
|
||||
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
|
||||
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
|
||||
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
|
||||
|
||||
#### 📔 Journal, Atlas and Vacay
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
- **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
|
||||
#### 📔 Journey and TREK Studio
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
|
||||
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
|
||||
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
|
||||
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
|
||||
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
|
||||
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🗺️ Atlas, Vacay and Collections
|
||||
|
||||
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI assistant and MCP
|
||||
|
||||
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
|
||||
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
|
||||
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
|
||||
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
|
||||
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Plugins
|
||||
|
||||
- **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
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
|
||||
- **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 layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 📱 Mobile and offline
|
||||
|
||||
- **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
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
|
||||
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
|
||||
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### ⚙️ Admin, accounts and security
|
||||
|
||||
- **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, ntfy and Web Push (on every device you turn it on for, even with TREK closed), 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
|
||||
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
|
||||
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
|
||||
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
|
||||
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
|
||||
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
|
||||
- **27 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, az, et, sk, th
|
||||
- **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
|
||||
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+3
-5
@@ -8,7 +8,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -41,9 +41,7 @@
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
@@ -94,7 +92,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
"complexity": 221,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"scripts/check-gl-split.mjs",
|
||||
"scripts/check-page-pattern.mjs",
|
||||
"scripts/generate-icons.mjs",
|
||||
"scripts/patch-maplibre.mjs",
|
||||
"src/App.test.tsx",
|
||||
"src/App.tsx",
|
||||
"src/App.versionHandover.test.tsx",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
|
||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
||||
|
||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
||||
const loops = [
|
||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
||||
]
|
||||
|
||||
for (const filename of [
|
||||
'src/util/dom.ts',
|
||||
'dist/maplibre-gl.js',
|
||||
'dist/maplibre-gl-dev.js',
|
||||
'dist/maplibre-gl-csp.js',
|
||||
'dist/maplibre-gl-csp-dev.js',
|
||||
]) {
|
||||
const target = join(dirname(packagePath), filename)
|
||||
const code = readFileSync(target, 'utf8')
|
||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
||||
const [before, after] = loop
|
||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
||||
}
|
||||
writeFileSync(target, code.replace(before, after))
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -295,7 +294,6 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
@@ -233,7 +232,3 @@ export const aiAdminApi = {
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const claudeConnectorApi = {
|
||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -102,6 +102,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(12.5, 'body')}>
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AssistantSurface({
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
journal,
|
||||
}: AssistantSurfaceProps) {
|
||||
const runtime = useAssistantRuntime(tripId);
|
||||
// The phone is always compact; a window is what its width says.
|
||||
@@ -43,8 +44,9 @@ export default function AssistantSurface({
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
journal,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat, journal]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* FE-AI-WELCOME-001 to FE-AI-WELCOME-002
|
||||
*
|
||||
* The empty chat asks what fits the chat it is: a journey's conversation
|
||||
* shown for its journal asks about the journal, the same conversation in TREK
|
||||
* Studio about the book, and each tells a reader what they may not change.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantWelcome from './AssistantWelcome';
|
||||
import { AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const JOURNAL_KEY = -12;
|
||||
|
||||
function welcome(canEdit: boolean, journal?: boolean) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: JOURNAL_KEY, variant: 'window', journal }}>
|
||||
<AssistantWelcome suggestions={[]} canEdit={canEdit} withSuggestions={false} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AssistantWelcome', () => {
|
||||
it("FE-AI-WELCOME-001: a journey's chat shown for its journal asks about the journal", () => {
|
||||
welcome(false, true);
|
||||
expect(screen.getByText('What should go into the journal?')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You can only read this journal/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WELCOME-002: the same chat in the Studio asks about the book', () => {
|
||||
welcome(true);
|
||||
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/You can only/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -27,33 +27,40 @@ export default function AssistantWelcome({
|
||||
withSuggestions: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
|
||||
const scope = useAssistantScope().kind;
|
||||
const book = scope === 'journey';
|
||||
const { tripId, variant, journal } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan; the journey's chat shown for its journal about the journal,
|
||||
// the home chat about all of the user's travels.
|
||||
const about = useAssistantScope().kind;
|
||||
const scope: WelcomeScope = about === 'journey' && journal ? 'journal' : about;
|
||||
const hints = useAssistantHints(tripId, { withStays: true });
|
||||
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
||||
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||
{!canEdit && (
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
|
||||
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
|
||||
</p>
|
||||
)}
|
||||
{!canEdit && <p className="m-0 max-w-xs text-center text-caption text-content-muted">{readOnlyNote(scope, t)}</p>}
|
||||
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
|
||||
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What an empty chat is about: a trip, a journey's book in the Studio, a journal on its page, or all of them. */
|
||||
type WelcomeScope = 'trip' | 'journey' | 'journal' | 'home';
|
||||
|
||||
/** The question over the empty chat, by what the chat is about. */
|
||||
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
|
||||
function welcomeTitle(scope: WelcomeScope, t: (key: string) => string): string {
|
||||
if (scope === 'journey') return t('journey.studio.assistant.welcome');
|
||||
if (scope === 'journal') return t('assistant.journal.welcome');
|
||||
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
|
||||
}
|
||||
|
||||
/** What the empty chat tells someone who may only read. */
|
||||
function readOnlyNote(scope: WelcomeScope, t: (key: string) => string): string {
|
||||
if (scope === 'journey') return t('journey.studio.assistant.readOnly');
|
||||
return scope === 'journal' ? t('assistant.journal.readOnly') : t('assistant.empty.readOnly');
|
||||
}
|
||||
|
||||
export function AssistantSuggestions({
|
||||
suggestions,
|
||||
layout,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router';
|
||||
|
||||
import type { WindowRect } from '../../hooks/useDragResize';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { HOME_CHAT_KEY, assistantScopeOf } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import AssistantFrameEdges from './AssistantFrameEdges';
|
||||
import AssistantHeader from './AssistantHeader';
|
||||
@@ -15,7 +15,7 @@ import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { useAssistantHome } from './useAssistantHome';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
@@ -33,7 +33,9 @@ import { useAssistantThread } from './useAssistantThread';
|
||||
*
|
||||
* Outside a trip it opens on the home chat, which knows all of the user's
|
||||
* trips; its head lists them to go on in one trip's own chat, and a trip's
|
||||
* chat opened that way has the way back home in its head.
|
||||
* chat opened that way has the way back home in its head. On a journey's
|
||||
* page it opens on that journal's chat, the journey's conversation it shares
|
||||
* with TREK Studio.
|
||||
*
|
||||
* Mounted once for the whole app; it shows nothing until the assistant has a
|
||||
* connection this user can use.
|
||||
@@ -170,12 +172,31 @@ function ChatWindow({
|
||||
onFocusDay={onFocusDay}
|
||||
onRevealMap={onRevealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
// The window never shows the Studio: a journey's chat in it is the journal's.
|
||||
journal={assistantScopeOf(trip.id).kind === 'journey'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which chat the window shows: the page's own (the trip in the planner, else
|
||||
* the journal on screen), else the one picked, else the home chat. `onScreen`
|
||||
* when it is the page's own, `inPlanner` when that page is the planner.
|
||||
*/
|
||||
function windowChat(
|
||||
trip: WindowTrip | null,
|
||||
journal: WindowTrip | null,
|
||||
picked: WindowTrip | null,
|
||||
homeChat: WindowTrip
|
||||
) {
|
||||
const routeChat = trip ?? journal;
|
||||
const current = routeChat ?? picked ?? homeChat;
|
||||
const onScreen = !!routeChat && current.id === routeChat.id;
|
||||
return { routeChat, current, onScreen, inPlanner: onScreen && trip !== null };
|
||||
}
|
||||
|
||||
export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap }: AssistantWindowProps) {
|
||||
const navigate = useNavigate();
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
@@ -198,13 +219,14 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
if (!open) setPicked(null);
|
||||
}
|
||||
|
||||
const current = trip ?? picked ?? home.chat;
|
||||
const journal = useRouteJournalChat();
|
||||
const { routeChat, current, onScreen, inPlanner } = windowChat(trip, journal, picked, home.chat);
|
||||
const atHome = current.id === HOME_CHAT_KEY;
|
||||
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
|
||||
// A change in a trip the planner does not show is shown by opening that trip (a journal, its page); a tab opens on it.
|
||||
const openPicked = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
if (target.kind === 'page') void navigate(target.path);
|
||||
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
|
||||
else if (picked) void navigate(`${chatPagePath(picked)}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
|
||||
},
|
||||
[navigate, picked]
|
||||
);
|
||||
@@ -216,19 +238,20 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
[navigate]
|
||||
);
|
||||
const goHome = useCallback(() => setPicked(null), []);
|
||||
// Another page leaves the trip: the chat stays on it, as if it had been picked.
|
||||
// Another page leaves the trip or the journal: the chat stays on it, as if it had been picked.
|
||||
const openOnScreen = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
if (target.kind === 'page' && trip) setPicked(trip);
|
||||
if (target.kind === 'page' && routeChat) setPicked(routeChat);
|
||||
onOpen?.(target);
|
||||
},
|
||||
[onOpen, trip]
|
||||
[onOpen, routeChat]
|
||||
);
|
||||
|
||||
// The round button's hint dot reads a trip's plan; a journal has none.
|
||||
const launcherTrip = trip?.id ?? null;
|
||||
if (!available) return null;
|
||||
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
|
||||
if (!open) return routeChat ? <AssistantLauncher variant="floating" tripId={launcherTrip} /> : null;
|
||||
|
||||
const onScreen = !!trip && current.id === trip.id;
|
||||
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
|
||||
if (atHome) openTarget = openFromHome;
|
||||
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
|
||||
@@ -241,13 +264,13 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
hidden={minimized}
|
||||
onOpen={openTarget}
|
||||
// Only the planner on screen has days to select and a map to bring back.
|
||||
onFocusDay={onScreen ? onFocusDay : undefined}
|
||||
onRevealMap={onScreen ? onRevealMap : undefined}
|
||||
onFocusDay={inPlanner ? onFocusDay : undefined}
|
||||
onRevealMap={inPlanner ? onRevealMap : undefined}
|
||||
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
||||
tripSwitch={tripSwitch}
|
||||
/>
|
||||
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
||||
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
|
||||
{minimized && <AssistantLauncher variant="floating" tripId={launcherTrip} focusOnMount />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,4 +121,40 @@ describe('the questions an answer offers', () => {
|
||||
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
||||
});
|
||||
|
||||
it('FE-AI-FOLLOW-008: the answer to an attached file offers what the server chose, also under its booking cards', () => {
|
||||
const read = {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: 'r',
|
||||
toolName: 'read_booking_file',
|
||||
args: { name: 'AirBnB Buchung.pdf' },
|
||||
result: { bookings: [], reason: 'ai_not_configured' },
|
||||
};
|
||||
const chips = {
|
||||
type: 'data-trek-follow-ups' as const,
|
||||
data: { questions: ['Keep it in Files', 'What is in the file?'] },
|
||||
};
|
||||
const empty = of([
|
||||
read,
|
||||
{ type: 'text', text: 'I could not find a booking. What should I do with the file?' },
|
||||
chips,
|
||||
]);
|
||||
expect(followUpsFor(empty, true).map((f) => f.text)).toEqual(['Keep it in Files', 'What is in the file?']);
|
||||
const change = {
|
||||
toolCallId: 'b',
|
||||
verbKey: 'importBooking',
|
||||
subject: 'Casa',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: false,
|
||||
};
|
||||
const found = of([
|
||||
read,
|
||||
{ type: 'text', text: 'I found 1 booking.' },
|
||||
{ type: 'data-trek-change', data: change as AssistantChangePreview },
|
||||
{ type: 'data-trek-follow-ups', data: { questions: ['Keep the file with this booking'] } },
|
||||
]);
|
||||
expect(followUpsFor(found, true).map((f) => f.text)).toEqual(['Keep the file with this booking']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -463,6 +463,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
track: { labelKey: 'assistant.change.label.track' },
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
sentTo: { labelKey: 'assistant.change.label.sentTo' },
|
||||
attachedTo: { labelKey: 'assistant.change.label.attachedTo' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface AssistantView {
|
||||
* chat's card that offers it; left out where no chat can be switched to.
|
||||
*/
|
||||
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
|
||||
/**
|
||||
* A journey's chat shown for its journal (on the journey's page, from the
|
||||
* home chat), not for its book in TREK Studio; left out in the Studio and
|
||||
* for a trip. The empty chat then speaks of the journal.
|
||||
*/
|
||||
journal?: boolean;
|
||||
}
|
||||
|
||||
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
|
||||
|
||||
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
|
||||
nordic: { labelKey: 'studio.theme.name.nordic' },
|
||||
alpine: { labelKey: 'studio.theme.name.alpine' },
|
||||
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
|
||||
riso: { labelKey: 'studio.theme.name.riso' },
|
||||
magazine: { labelKey: 'studio.theme.name.magazine' },
|
||||
'retro-travel': { labelKey: 'studio.theme.name.retroTravel' },
|
||||
midnight: { labelKey: 'studio.theme.name.midnight' },
|
||||
'field-notes': { labelKey: 'studio.theme.name.fieldNotes' },
|
||||
};
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
@@ -65,6 +65,13 @@ describe('cardLooks', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-003b: a file kept from the chat opens the trip Files and shows nothing on the map', () => {
|
||||
const kept: AssistantApplied = { toolCallId: 'k', entity: 'file', ids: [], dayId: null, undoUntil: null };
|
||||
expect(ENTITY_LOOK.file.open?.(kept, null)).toEqual({ kind: 'tab', tab: 'dateien' });
|
||||
expect(ENTITY_LOOK.file.onMap).toBeUndefined();
|
||||
expect(ENTITY_LOOK.file.openLabelKey).toBe('assistant.card.file.open');
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
|
||||
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
|
||||
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
CalendarMinus,
|
||||
CalendarPlus,
|
||||
Circle,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FolderInput,
|
||||
ListTodo,
|
||||
Luggage,
|
||||
MapPin,
|
||||
@@ -63,6 +66,8 @@ export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
|
||||
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
|
||||
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
|
||||
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
|
||||
readAttachment: { Icon: FileSearch, tone: 'info', labelKey: 'assistant.change.verb.readAttachment' },
|
||||
keepFile: { Icon: FolderInput, tone: 'success', labelKey: 'assistant.change.verb.keepFile' },
|
||||
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
|
||||
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
|
||||
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
|
||||
@@ -159,6 +164,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||
},
|
||||
search_help: { stepKey: 'assistant.step.help' },
|
||||
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
|
||||
read_attached_file: { stepKey: 'assistant.step.readingAttachment' },
|
||||
...TOUR_LOOK.steps,
|
||||
...ROADTRIP_LOOK.steps,
|
||||
...BUDGET_LOOK.steps,
|
||||
@@ -193,6 +199,8 @@ export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook
|
||||
assignment: { Icon: CalendarDays, onMap: BY_DAY },
|
||||
day_note: { Icon: StickyNote, onMap: BY_DAY },
|
||||
memory: { Icon: NotebookPen },
|
||||
// A file kept from the chat: it opens the trip's Files.
|
||||
file: { Icon: FileText, openLabelKey: 'assistant.card.file.open', open: () => ({ kind: 'tab', tab: 'dateien' }) },
|
||||
other: { Icon: Circle },
|
||||
...TOUR_LOOK.entities,
|
||||
...ROADTRIP_LOOK.entities,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ArrowRight, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type AssistantChatTrip, useAssistantView } from '../../assistantViewContext';
|
||||
|
||||
/**
|
||||
* What the home chat's cards that go on in another chat share: the question
|
||||
* handed over, waiting there ready to send, and the button that switches to
|
||||
* that chat. The button shows only where a chat can be switched to.
|
||||
*/
|
||||
|
||||
/** The question that waits in the other chat's composer. */
|
||||
export function HandedQuestion({ question }: { question: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!question) return null;
|
||||
return (
|
||||
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
||||
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
||||
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
||||
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The button that goes on in the chat under `chat`, with the question handed over. */
|
||||
export function OpenChatButton({
|
||||
chat,
|
||||
question,
|
||||
label,
|
||||
}: {
|
||||
chat: AssistantChatTrip;
|
||||
question: string | null;
|
||||
label: string;
|
||||
}) {
|
||||
const { onOpenTripChat, variant } = useAssistantView();
|
||||
if (!onOpenTripChat) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTripChat(chat, question)}
|
||||
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
||||
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* FE-AI-JOURNALCHAT-001 to FE-AI-JOURNALCHAT-003
|
||||
*
|
||||
* The home chat's card that offers a journal's own chat: the journal with
|
||||
* its subtitle or days and what it holds, the question waiting there, and the
|
||||
* button that goes on in the chat kept under the journey's key.
|
||||
*/
|
||||
import type { JournalChatCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
import { JournalChatBlock } from './JournalChatBlock';
|
||||
|
||||
const JAPAN: JournalChatCard = {
|
||||
kind: 'journalChat',
|
||||
journey: {
|
||||
id: 12,
|
||||
title: 'Japan 2026',
|
||||
subtitle: null,
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-16',
|
||||
entryCount: 4,
|
||||
photoCount: 120,
|
||||
},
|
||||
question: 'Make a new entry for Berlin Hauptbahnhof',
|
||||
};
|
||||
|
||||
function drawn(
|
||||
cards: JournalChatCard[],
|
||||
onOpenTripChat?: (chat: { id: number; title: string }, q: string | null) => void
|
||||
) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: 0, variant: 'fullscreen', onOpenTripChat }}>
|
||||
<JournalChatBlock cards={cards.map((card, index) => ({ toolCallId: `j${index}`, card }))} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('JournalChatBlock', () => {
|
||||
it('FE-AI-JOURNALCHAT-001: shows the journal, its days, what it holds and the question', () => {
|
||||
drawn([JAPAN]);
|
||||
expect(screen.getByRole('region', { name: 'Continue in a journal' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 entries')).toBeInTheDocument();
|
||||
expect(screen.getByText('120 photos')).toBeInTheDocument();
|
||||
expect(screen.getByText('Make a new entry for Berlin Hauptbahnhof')).toBeInTheDocument();
|
||||
// Nowhere to switch to: no button.
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-JOURNALCHAT-002: the button goes on in the journal's chat, under the journey's key", () => {
|
||||
const open = vi.fn();
|
||||
drawn([JAPAN], open);
|
||||
fireEvent.click(screen.getByRole('button', { name: "Continue in this journal's chat" }));
|
||||
expect(open).toHaveBeenCalledWith({ id: -12, title: 'Japan 2026' }, 'Make a new entry for Berlin Hauptbahnhof');
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNALCHAT-003: a subtitle stands for the days, an empty journal shows no photos, nothing draws nothing', () => {
|
||||
const bare: JournalChatCard = {
|
||||
...JAPAN,
|
||||
journey: { ...JAPAN.journey, subtitle: 'Autumn', startDate: null, endDate: null, entryCount: 0, photoCount: 0 },
|
||||
question: null,
|
||||
};
|
||||
drawn([bare]);
|
||||
expect(screen.getByText('Autumn')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 entries')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/photos?$/)).not.toBeInTheDocument();
|
||||
const { container } = render(<JournalChatBlock cards={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { JournalChatCard } from '@trek/shared';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { assistantChatKey } from '../../../../store/assistantChatKey';
|
||||
import { WhiteBadge } from '../../../Planner/planParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { chatDates } from './chatDates';
|
||||
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
|
||||
|
||||
/**
|
||||
* The home chat's way onto a journal's own page: the journal under its head
|
||||
* (its subtitle or days, how many entries and photos it holds), the question
|
||||
* that waits there ready to send, and the button that goes on in the
|
||||
* journal's chat. That chat is the journey's conversation on its page, kept
|
||||
* under the journey's key; the window switches to it in place, the phone
|
||||
* opens the journal with its chat.
|
||||
*/
|
||||
|
||||
function JournalFacts({ card }: { card: JournalChatCard }) {
|
||||
const { t } = useTranslation();
|
||||
const { entryCount, photoCount } = card.journey;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<WhiteBadge>{t('assistant.card.journey.entries', { count: entryCount })}</WhiteBadge>
|
||||
{photoCount > 0 && <WhiteBadge>{t('assistant.card.journey.photos', { count: photoCount })}</WhiteBadge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JournalChatSection({ card, first }: { card: JournalChatCard; first: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const { journey, question } = card;
|
||||
const meta = journey.subtitle ?? chatDates(t, format.date, journey.startDate, journey.endDate);
|
||||
const chat = { id: assistantChatKey({ kind: 'journey', journeyId: journey.id }), title: journey.title };
|
||||
return (
|
||||
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||
{first ? (
|
||||
<CardHeader
|
||||
icon={BookOpen}
|
||||
tone="info"
|
||||
eyebrow={t('assistant.card.journalChat.eyebrow')}
|
||||
title={journey.title}
|
||||
meta={meta}
|
||||
>
|
||||
<JournalFacts card={card} />
|
||||
</CardHeader>
|
||||
) : (
|
||||
<div className="mb-2.5">
|
||||
<bdi className="block truncate text-body font-bold text-content">{journey.title}</bdi>
|
||||
<div className="mt-0.5 text-caption text-content-muted">{meta}</div>
|
||||
<JournalFacts card={card} />
|
||||
</div>
|
||||
)}
|
||||
<HandedQuestion question={question} />
|
||||
<OpenChatButton chat={chat} question={question} label={t('assistant.card.journalChat.open')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JournalChatBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const journals = cards.flatMap((entry) => (entry.card.kind === 'journalChat' ? [entry.card] : []));
|
||||
if (journals.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.journalChat.label')}>
|
||||
{journals.map((card, index) => (
|
||||
<JournalChatSection key={`${card.journey.id}-${index}`} card={card} first={index === 0} />
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { TripChatCard, TripChatPhase } from '@trek/shared';
|
||||
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
|
||||
import { Luggage } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { chatDates } from './chatDates';
|
||||
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
|
||||
|
||||
/**
|
||||
* The home chat's way into one trip's own chat: the trip under its head (its
|
||||
@@ -27,18 +28,9 @@ const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: stri
|
||||
|
||||
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { onOpenTripChat, variant } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
const { trip, question } = card;
|
||||
const start = trip.startDate ?? trip.endDate;
|
||||
const end = trip.endDate ?? trip.startDate;
|
||||
let dates = t('assistant.card.tripChat.noDates');
|
||||
if (start && end) {
|
||||
dates =
|
||||
start === end
|
||||
? format.date(start)
|
||||
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
|
||||
}
|
||||
const dates = chatDates(t, format.date, trip.startDate, trip.endDate);
|
||||
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
|
||||
return (
|
||||
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||
@@ -59,25 +51,12 @@ function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }
|
||||
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
||||
</div>
|
||||
)}
|
||||
{question && (
|
||||
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
||||
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
||||
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
||||
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
||||
</p>
|
||||
)}
|
||||
{onOpenTripChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
|
||||
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
||||
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
||||
}`}
|
||||
>
|
||||
{t('assistant.card.tripChat.open')}
|
||||
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
<HandedQuestion question={question} />
|
||||
<OpenChatButton
|
||||
chat={{ id: trip.id, title: trip.title }}
|
||||
question={question}
|
||||
label={t('assistant.card.tripChat.open')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The days a card of another chat names under its head: one day, a span, or
|
||||
* that it has none yet. Pure, so the trip's card and the journal's read one
|
||||
* wording.
|
||||
*/
|
||||
export function chatDates(
|
||||
t: (key: string, params?: Record<string, string>) => string,
|
||||
date: (value: string) => string,
|
||||
startDate: string | null,
|
||||
endDate: string | null
|
||||
): string {
|
||||
const start = startDate ?? endDate;
|
||||
const end = endDate ?? startDate;
|
||||
if (!start || !end) return t('assistant.card.tripChat.noDates');
|
||||
if (start === end) return date(start);
|
||||
return t('assistant.card.tripChat.dates', { start: date(start), end: date(end) });
|
||||
}
|
||||
@@ -13,9 +13,11 @@ export const HOME_LOOK = {
|
||||
steps: {
|
||||
list_trips: { stepKey: 'assistant.step.readingTrips' },
|
||||
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
|
||||
open_journey_chat: { stepKey: 'assistant.step.openJournalChat' },
|
||||
},
|
||||
entities: {},
|
||||
cards: {
|
||||
tripChat: {},
|
||||
journalChat: {},
|
||||
},
|
||||
} satisfies PackLook<'tripChat'>;
|
||||
} satisfies PackLook<'tripChat' | 'journalChat'>;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { CardView } from '../cardTypes';
|
||||
import { JournalChatBlock } from './JournalChatBlock';
|
||||
import { TripChatBlock } from './TripChatBlock';
|
||||
|
||||
/**
|
||||
* How the home chat's kind draws: the trip it offers to go on with, beside
|
||||
* the answer. This file only maps the kind to its view; the component lives
|
||||
* How the home chat's kinds draw: the trip or the journal it offers to go on
|
||||
* with, beside the answer. This file only maps the kind to its view; the component lives
|
||||
* in a sibling file, since a file that exports anything besides components
|
||||
* breaks fast refresh.
|
||||
*/
|
||||
export const HOME_VIEWS = {
|
||||
tripChat: { Block: TripChatBlock },
|
||||
} satisfies { [K in 'tripChat']: CardView<K> };
|
||||
journalChat: { Block: JournalChatBlock },
|
||||
} satisfies { [K in 'tripChat' | 'journalChat']: CardView<K> };
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { AssistantChangeRow, JourneyEntriesCard } from '@trek/shared';
|
||||
import { AlignLeft, CalendarDays, Clock, MapPin, Tag, Type } from 'lucide-react';
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowDownUp,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
LocateFixed,
|
||||
MapPin,
|
||||
Tag,
|
||||
Tags,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
import { EntryMarks, EntryStory, EntryThumb } from './JourneyEntryParts';
|
||||
@@ -20,7 +33,9 @@ import { entryHoverProps, useEntryHover } from './useEntryHover';
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
|
||||
/** The facts a journal change row names on its faint line, in this order: when, then where. */
|
||||
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin } as const;
|
||||
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin, coordinates: LocateFixed } as const;
|
||||
/** The lists an update rewrites, each by its glyph: the tags and the two sides of the verdict. */
|
||||
const LIST_ICONS: Readonly<Record<string, typeof Tag>> = { tags: Tags, pros: ThumbsUp, cons: ThumbsDown };
|
||||
/** The words an update rewrites, named only when they change: the entry's title and its text. */
|
||||
const WORD_ICONS = { name: Type, text: AlignLeft } as const;
|
||||
type FactRow = AssistantChangeRow & { labelKey: keyof typeof FACT_ICONS };
|
||||
@@ -45,7 +60,9 @@ function StateChange({ row }: { row: AssistantChangeRow }) {
|
||||
if (flag) return <FactChange icon={flag.Icon} before={null} after={t(flag.labelKey)} tone="info" />;
|
||||
const was = stateOf(row.field, row.before);
|
||||
const now = stateOf(row.field, row.after);
|
||||
const Icon = (now ?? was)?.Icon ?? Tag;
|
||||
const listed =
|
||||
row.field && Object.prototype.hasOwnProperty.call(LIST_ICONS, row.field) ? LIST_ICONS[row.field] : undefined;
|
||||
const Icon = (now ?? was)?.Icon ?? listed ?? Tag;
|
||||
const named = (tone: typeof now, value: string | null) => (tone ? t(tone.labelKey) : value);
|
||||
return (
|
||||
<FactChange
|
||||
@@ -110,12 +127,32 @@ export function JourneyEntryAside({ card, item }: { card: JourneyEntriesCard; it
|
||||
);
|
||||
}
|
||||
|
||||
/** A new order: the entries that move, in their new places, each with its day; the rest counted. */
|
||||
function NewOrder({ card }: { card: JourneyEntriesCard }) {
|
||||
const { t, locale } = useTranslation();
|
||||
return (
|
||||
<ol className="m-0 mt-1 list-none space-y-0.5 p-0">
|
||||
{card.entries.map((look, index) => (
|
||||
<li key={look.entryId ?? index} className="flex items-center gap-1.5 text-caption text-content-secondary">
|
||||
<ArrowDownUp size={11} aria-hidden="true" className="flex-none text-info" />
|
||||
<bdi className="min-w-0 truncate">{look.title ?? look.place ?? cardDateLabel(look.date, locale)}</bdi>
|
||||
<span className="flex-none text-content-faint">{cardDateLabel(look.date, locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
{card.more > 0 && (
|
||||
<li className="text-caption text-content-faint">{t('assistant.card.common.more', { count: card.more })}</li>
|
||||
)}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** The start of the entry's text and its marks, under the row; resting on them shows its place on the map. */
|
||||
export function JourneyEntryBody({ card, item }: { card: JourneyEntriesCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
const hover = useEntryHover(card.journeyId, item.toolCallId);
|
||||
const look = card.entries[0];
|
||||
if (!look || GONE.has(item.state)) return null;
|
||||
if (card.change === 'reordered') return <NewOrder card={card} />;
|
||||
return (
|
||||
<div {...entryHoverProps(hover, look)}>
|
||||
<EntryStory look={look} roomy={false} />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* FE-AI-JOURNEY-020 to FE-AI-JOURNEY-022
|
||||
*
|
||||
* The parts of a journal entry the chat added later: its tags and how its
|
||||
* verdict leans among its marks, an update's tags, verdict and coordinates
|
||||
* old to new, and a new order of the entries drawn as the entries that move.
|
||||
*/
|
||||
import type { AssistantChangePreview, JourneyEntriesCard, JourneyEntryLook } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../../../tests/helpers/render';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
|
||||
const ENTRY: JourneyEntryLook = {
|
||||
entryId: 12,
|
||||
type: 'entry',
|
||||
title: 'Sunset at the Spree',
|
||||
date: '2026-10-21',
|
||||
time: null,
|
||||
place: null,
|
||||
point: null,
|
||||
countryCode: null,
|
||||
story: null,
|
||||
mood: null,
|
||||
weather: null,
|
||||
photoId: null,
|
||||
video: false,
|
||||
photoCount: 0,
|
||||
visibility: 'private',
|
||||
draft: false,
|
||||
fromTripPlace: false,
|
||||
dayIndex: null,
|
||||
tags: ['river', 'evening'],
|
||||
verdict: { pros: ['the light'], cons: ['cold', 'wind'], prosCount: 1, consCount: 2 },
|
||||
};
|
||||
|
||||
function journal(entries: JourneyEntryLook[], change: JourneyEntriesCard['change'], more = 0): JourneyEntriesCard {
|
||||
return { kind: 'journeyEntries', journeyId: 3, journalTitle: 'Berlin', tripJournal: false, change, entries, more };
|
||||
}
|
||||
|
||||
function pending(
|
||||
card: JourneyEntriesCard,
|
||||
verbKey: AssistantChangePreview['verbKey'],
|
||||
rows: AssistantChangePreview['rows']
|
||||
) {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'e1',
|
||||
verbKey,
|
||||
subject: 'Berlin',
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
domain: 'journey',
|
||||
card,
|
||||
};
|
||||
const item: ChangeItem = { toolCallId: 'e1', preview, applied: null, state: 'pending', card };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('the later parts of a journal entry', () => {
|
||||
it('FE-AI-JOURNEY-020: a new entry shows its tags and how its verdict leans', () => {
|
||||
pending(journal([ENTRY], 'new'), 'addJournalEntry', []);
|
||||
expect(screen.getByText('river')).toBeInTheDocument();
|
||||
expect(screen.getByText('evening')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 good point')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 weak points')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEY-021: an update names its tags, each side of its verdict and its coordinates, old to new', () => {
|
||||
const rows: AssistantChangePreview['rows'] = [
|
||||
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
|
||||
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
|
||||
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
|
||||
];
|
||||
pending(journal([{ ...ENTRY, tags: [], verdict: null }], 'changed'), 'updateJournalEntry', rows);
|
||||
expect(screen.getByText('river, evening')).toHaveClass('line-through');
|
||||
expect(screen.getByText('cold')).toBeInTheDocument();
|
||||
expect(screen.getByText('52.5200, 13.4050')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEY-022: a new order lists the entries that move in their new places, the rest counted', () => {
|
||||
const moved = [
|
||||
{ ...ENTRY, entryId: 2, title: 'Museum Island', moved: true },
|
||||
{ ...ENTRY, entryId: 1, title: 'Arrival', moved: true },
|
||||
];
|
||||
pending(journal(moved, 'reordered', 3), 'reorderJournalEntries', []);
|
||||
expect(screen.getByText('Put the entries in a new order')).toBeInTheDocument();
|
||||
const text = document.body.textContent ?? '';
|
||||
expect(text.indexOf('Museum Island')).toBeGreaterThan(-1);
|
||||
expect(text.indexOf('Museum Island')).toBeLessThan(text.indexOf('Arrival'));
|
||||
expect(screen.getByText('3 more')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JourneyEntryLook } from '@trek/shared';
|
||||
import { CalendarDays, Globe, Images, MapPin, Play, Users } from 'lucide-react';
|
||||
import { CalendarDays, Globe, Images, MapPin, Play, Tag, ThumbsDown, ThumbsUp, Users } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel, cardPlace } from '../../../Journey/journeyCard';
|
||||
@@ -101,11 +101,42 @@ function WeatherMark({ weather }: { weather: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first. */
|
||||
/** Its tags and how its verdict leans: the good and the weak points counted. */
|
||||
function ListMarks({ look }: { look: JourneyEntryLook }) {
|
||||
const { t } = useTranslation();
|
||||
const verdict = look.verdict ?? null;
|
||||
return (
|
||||
<>
|
||||
{(look.tags ?? []).map((tag, index) => (
|
||||
<SoftPill
|
||||
key={`${tag}-${index}`}
|
||||
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
|
||||
>
|
||||
<bdi className="truncate">{tag}</bdi>
|
||||
</SoftPill>
|
||||
))}
|
||||
{verdict && verdict.prosCount > 0 && (
|
||||
<SoftPill tone="success" icon={<ThumbsUp size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.journey.pros', { count: verdict.prosCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
{verdict && verdict.consCount > 0 && (
|
||||
<SoftPill tone="warning" icon={<ThumbsDown size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.journey.cons', { count: verdict.consCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first, its tags and verdict. */
|
||||
export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
||||
const { t } = useTranslation();
|
||||
const mood = moodOf(look.mood) !== null;
|
||||
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2) return null;
|
||||
const lists = (look.tags?.length ?? 0) > 0 || !!look.verdict;
|
||||
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2 && !lists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CardFacts>
|
||||
<VisibilityMark visibility={look.visibility} />
|
||||
@@ -117,6 +148,7 @@ export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
||||
{t('assistant.card.journey.photos', { count: look.photoCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
<ListMarks look={look} />
|
||||
</CardFacts>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* FE-AI-JOURNEYHEADER-001 to FE-AI-JOURNEYHEADER-005
|
||||
*
|
||||
* A change of a journal itself in the chat: the settings an update sets by
|
||||
* the journal's own names, old to new; the trips under the row with the one
|
||||
* the change links or unlinks marked; what a new journal brings and what a
|
||||
* deletion or an unlink takes; the receipt that opens the journal.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangePreview, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../../store/assistantStore';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
import { headerValueKey, settingRows } from './journeyHeaderModel';
|
||||
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
});
|
||||
|
||||
const TOKYO: JourneyHeaderTrip = {
|
||||
id: 5,
|
||||
title: 'Tokyo days',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-08',
|
||||
change: 'kept',
|
||||
};
|
||||
|
||||
const JAPAN: JourneyHeaderCard = {
|
||||
kind: 'journeyHeader',
|
||||
journeyId: 12,
|
||||
title: 'Japan 2026',
|
||||
subtitle: null,
|
||||
change: 'changed',
|
||||
trips: [TOKYO],
|
||||
entries: 3,
|
||||
photos: 40,
|
||||
suggestions: null,
|
||||
};
|
||||
|
||||
function change(
|
||||
state: ChangeItem['state'],
|
||||
card: JourneyHeaderCard,
|
||||
over: Partial<AssistantChangePreview> = {}
|
||||
): ChangeItem {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'h1',
|
||||
verbKey: 'updateJournalSettings',
|
||||
subject: 'Japan 2026',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
domain: 'journey',
|
||||
card,
|
||||
...over,
|
||||
};
|
||||
const applied: AssistantApplied = { toolCallId: 'h1', entity: 'journey', ids: [12], dayId: null, undoUntil: null };
|
||||
return { toolCallId: 'h1', preview, applied: state === 'applied' ? applied : null, state, card };
|
||||
}
|
||||
|
||||
function shown(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -12, variant: 'window', onOpen }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('a change of a journal itself', () => {
|
||||
it('FE-AI-JOURNEYHEADER-001: an update names each setting by the journal own words, old to new', () => {
|
||||
const rows: AssistantChangePreview['rows'] = [
|
||||
{ op: 'change', labelKey: 'name', before: 'Japan', after: 'Japan 2026' },
|
||||
{ op: 'change', labelKey: 'field', field: 'show_mood', before: '✓', after: '✗' },
|
||||
{ op: 'change', labelKey: 'field', field: 'status_override', before: '', after: 'live' },
|
||||
];
|
||||
shown(change('pending', JAPAN, { rows }));
|
||||
expect(screen.getByText("Change the journal's settings")).toBeInTheDocument();
|
||||
expect(screen.getByText('Japan')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Mood: On')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Mood: Off')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shown as: Automatic')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Shown as: Live')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo days')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-002: a new journal shows its subtitle, the trips it links and the places that come in', () => {
|
||||
const fresh: JourneyHeaderCard = {
|
||||
...JAPAN,
|
||||
journeyId: null,
|
||||
change: 'new',
|
||||
subtitle: 'Autumn',
|
||||
trips: [{ ...TOKYO, change: 'link' }],
|
||||
entries: null,
|
||||
photos: null,
|
||||
suggestions: 9,
|
||||
};
|
||||
shown(change('pending', fresh, { verbKey: 'createJournal', undoable: true }));
|
||||
expect(screen.getByText('Start a journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Autumn')).toBeInTheDocument();
|
||||
expect(screen.getByText('Will be added')).toBeInTheDocument();
|
||||
expect(screen.getByText('9 places come in as suggestions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-003: a deletion warns that everything goes; an unlink says what stays and what goes', () => {
|
||||
const first = shown(change('pending', { ...JAPAN, change: 'willDelete' }, { verbKey: 'deleteJournal' }));
|
||||
expect(screen.getByText('Delete the journal')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This cannot be undone\./)).toBeInTheDocument();
|
||||
first.unmount();
|
||||
const unlink: JourneyHeaderCard = {
|
||||
...JAPAN,
|
||||
trips: [{ ...TOKYO, change: 'unlink' }],
|
||||
entries: 1,
|
||||
suggestions: 4,
|
||||
};
|
||||
shown(change('pending', unlink, { verbKey: 'unlinkJournalTrip' }));
|
||||
expect(screen.getByText('Tokyo days')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Will be taken out')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 written entry stays')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 open suggestions go')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-004: the receipt opens the journal, a change that failed shows its row alone', () => {
|
||||
const onOpen = vi.fn();
|
||||
const applied = shown(change('applied', JAPAN), onOpen);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open the journal' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/12' });
|
||||
applied.unmount();
|
||||
shown(change('failed', JAPAN));
|
||||
expect(screen.queryByText('Tokyo days')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-005: the model reads a status by its name and a switch as on or off', () => {
|
||||
expect(headerValueKey('status', 'archived')).toBe('journey.status.archived');
|
||||
expect(headerValueKey('status_override', null)).toBe('journey.settings.statusAuto');
|
||||
expect(headerValueKey('show_mood', '✓')).toBe('settings.on');
|
||||
expect(headerValueKey('cover_gradient', 'linear-gradient(red, blue)')).toBeNull();
|
||||
expect(headerValueKey('status', 'toString')).toBeNull();
|
||||
const rows: AssistantChangePreview['rows'] = [
|
||||
{ op: 'add', labelKey: 'field', field: 'trip', before: null, after: 'Kyoto' },
|
||||
{ op: 'add', labelKey: 'field', field: 'show_weather', before: null, after: '✓' },
|
||||
{ op: 'add', labelKey: 'field', field: 'unknown', before: null, after: 'x' },
|
||||
{ op: 'change', labelKey: 'name', before: 'A', after: 'B' },
|
||||
];
|
||||
expect(settingRows(rows).map((row) => row.field)).toEqual(['show_weather']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { AssistantChangeRow, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
|
||||
import { AlignLeft, Luggage, Type } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type PillTone, SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { headerFieldOf, headerValueKey, settingRows } from './journeyHeaderModel';
|
||||
|
||||
/**
|
||||
* What a change row of a journal itself adds to the row it already is (its
|
||||
* verb and the journal's name): on the faint line the name, subtitle and
|
||||
* settings it changes, each old to new; under the row the journal's trips,
|
||||
* the one the change links or unlinks marked, and what the change brings or
|
||||
* takes along. A change that failed, was discarded or ran out shows the row
|
||||
* alone.
|
||||
*/
|
||||
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
const WORD_ICONS = { name: Type, description: AlignLeft } as const;
|
||||
const NOTE = 'm-0 mt-1.5 text-caption';
|
||||
|
||||
const TRIP_TONE: Readonly<Record<JourneyHeaderTrip['change'], PillTone>> = {
|
||||
kept: 'neutral',
|
||||
link: 'success',
|
||||
unlink: 'danger',
|
||||
};
|
||||
|
||||
/** One setting a change sets, by its name, old to new. */
|
||||
function SettingChange({ row }: { row: AssistantChangeRow }) {
|
||||
const { t } = useTranslation();
|
||||
const look = headerFieldOf(row.field);
|
||||
if (!look) return null;
|
||||
const named = (value: string | null) => {
|
||||
const key = headerValueKey(row.field, value);
|
||||
if (key) return t(key);
|
||||
return value || null;
|
||||
};
|
||||
const before = row.op === 'add' ? null : named(row.before);
|
||||
const after = named(row.after) ?? t('assistant.card.common.removed');
|
||||
const name = t(look.labelKey);
|
||||
return (
|
||||
<FactChange
|
||||
icon={look.Icon}
|
||||
before={before === null ? null : t('assistant.card.journeyHeader.setting', { name, value: before })}
|
||||
after={t('assistant.card.journeyHeader.setting', { name, value: after })}
|
||||
tone="info"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The journal's new name and subtitle and the settings a change sets. */
|
||||
export function JourneyHeaderFacts({ item }: { card: JourneyHeaderCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = item.preview?.rows ?? [];
|
||||
const words = rows.filter(
|
||||
(row): row is AssistantChangeRow & { labelKey: keyof typeof WORD_ICONS } =>
|
||||
row.op === 'change' && row.labelKey in WORD_ICONS
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{words.map((row) => (
|
||||
<FactChange
|
||||
key={row.labelKey}
|
||||
icon={WORD_ICONS[row.labelKey]}
|
||||
before={row.before || null}
|
||||
after={row.after || t('assistant.card.common.removed')}
|
||||
tone="info"
|
||||
/>
|
||||
))}
|
||||
{settingRows(rows).map((row) => (
|
||||
<SettingChange key={row.field} row={row} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TripChip({ trip }: { trip: JourneyHeaderTrip }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const days = trip.startDate ? format.date(trip.startDate) : null;
|
||||
return (
|
||||
<SoftPill
|
||||
tone={TRIP_TONE[trip.change]}
|
||||
className="max-w-full"
|
||||
icon={<Luggage size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
|
||||
>
|
||||
{trip.change === 'link' && <span className="sr-only">{t('assistant.card.journeyHeader.tripLink')}</span>}
|
||||
{trip.change === 'unlink' && <span className="sr-only">{t('assistant.card.journeyHeader.tripUnlink')}</span>}
|
||||
<bdi className={`truncate ${trip.change === 'unlink' ? 'line-through' : ''}`}>{trip.title}</bdi>
|
||||
{days && <span className="text-content-faint">{days}</span>}
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the change brings along or takes with it, in the journal's words. */
|
||||
function HeaderNotes({ card }: { card: JourneyHeaderCard }) {
|
||||
const { t } = useTranslation();
|
||||
const unlinking = card.trips.some((trip) => trip.change === 'unlink');
|
||||
if (card.change === 'willDelete') {
|
||||
return <p className={`${NOTE} text-danger`}>{t('assistant.card.journeyHeader.deletes')}</p>;
|
||||
}
|
||||
if (unlinking) {
|
||||
return (
|
||||
<>
|
||||
{card.entries !== null && card.entries > 0 && (
|
||||
<p className={`${NOTE} text-content-muted`}>
|
||||
{t('assistant.card.journeyHeader.entriesStay', { count: card.entries })}
|
||||
</p>
|
||||
)}
|
||||
{card.suggestions !== null && card.suggestions > 0 && (
|
||||
<p className={`${NOTE} text-content-muted`}>
|
||||
{t('assistant.card.journeyHeader.suggestionsGo', { count: card.suggestions })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (card.suggestions === null || card.suggestions === 0) return null;
|
||||
return (
|
||||
<p className={`${NOTE} text-content-muted`}>
|
||||
{t('assistant.card.journeyHeader.suggestionsCome', { count: card.suggestions })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The journal's trips and what the change brings or takes, under the row. */
|
||||
export function JourneyHeaderBody({ card, item }: { card: JourneyHeaderCard; item: ChangeItem }) {
|
||||
if (GONE.has(item.state)) return null;
|
||||
return (
|
||||
<div>
|
||||
{card.subtitle && card.change === 'new' && (
|
||||
<p className="m-0 text-caption text-content-muted">
|
||||
<bdi>{card.subtitle}</bdi>
|
||||
</p>
|
||||
)}
|
||||
{card.trips.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{card.trips.map((trip, index) => (
|
||||
<TripChip key={`${trip.id ?? 'x'}-${index}`} trip={trip} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<HeaderNotes card={card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
|
||||
*
|
||||
* A change of a journal's photos in the chat: the entries the photos leave
|
||||
* and reach, the photos as thumbnails, a caption old to new, what stays
|
||||
* where, and the receipt that opens the entry.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
|
||||
const MOVE: JourneyPhotosCard = {
|
||||
kind: 'journeyPhotos',
|
||||
journeyId: 3,
|
||||
mode: 'move',
|
||||
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
|
||||
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
|
||||
photos: [
|
||||
{ photoId: 101, video: false, caption: 'Black sand' },
|
||||
{ photoId: 102, video: true, caption: null },
|
||||
],
|
||||
more: 4,
|
||||
kept: false,
|
||||
caption: null,
|
||||
};
|
||||
|
||||
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'p1',
|
||||
verbKey,
|
||||
subject: 'Reynisfjara',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: false,
|
||||
domain: 'journey',
|
||||
card,
|
||||
};
|
||||
const receipt: AssistantApplied = {
|
||||
toolCallId: 'p1',
|
||||
entity: 'journey_entry',
|
||||
ids: [2],
|
||||
dayId: null,
|
||||
undoUntil: null,
|
||||
};
|
||||
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
|
||||
}
|
||||
|
||||
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('a change of a journal photos', () => {
|
||||
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
|
||||
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vik')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
|
||||
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
||||
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
|
||||
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
|
||||
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
|
||||
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
|
||||
expect(screen.getByText('Entry cover')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
|
||||
const caption = {
|
||||
...MOVE,
|
||||
mode: 'caption' as const,
|
||||
from: null,
|
||||
to: null,
|
||||
more: 0,
|
||||
photos: [{ photoId: 101, video: false, caption: 'Old' }],
|
||||
};
|
||||
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Old')).toHaveClass('line-through');
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Removed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
|
||||
const onOpen = vi.fn();
|
||||
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
|
||||
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
|
||||
/**
|
||||
* What a change row of a journal's photos adds under the row: the entries
|
||||
* the photos leave and reach, the photos themselves as small thumbnails (the
|
||||
* first marked as the entry's cover in a new order), a caption old to new,
|
||||
* and what the change leaves where. A change that failed, was discarded or
|
||||
* ran out shows the row alone.
|
||||
*/
|
||||
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
|
||||
|
||||
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
|
||||
const { locale } = useTranslation();
|
||||
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
|
||||
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
|
||||
}
|
||||
|
||||
/** The entries the photos leave and reach, the way between them marked. */
|
||||
function Route({ card }: { card: JourneyPhotosCard }) {
|
||||
if (!card.from && !card.to) return null;
|
||||
return (
|
||||
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
|
||||
{card.from && <EntryName entry={card.from} />}
|
||||
{card.from && card.to && (
|
||||
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
)}
|
||||
{card.to && <EntryName entry={card.to} />}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbs({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.photos.length === 0 && card.more === 0) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.photos.map((photo, index) => (
|
||||
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
|
||||
<img
|
||||
src={`/api/photos/${photo.photoId}/thumbnail`}
|
||||
alt={photo.caption ?? ''}
|
||||
loading="lazy"
|
||||
className="size-full rounded-lg bg-surface-secondary object-cover"
|
||||
/>
|
||||
{photo.video && (
|
||||
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
|
||||
<span className="sr-only">{t('assistant.card.journey.video')}</span>
|
||||
</SoftPill>
|
||||
)}
|
||||
{card.mode === 'order' && index === 0 && (
|
||||
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{card.more > 0 && (
|
||||
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.common.more', { count: card.more })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
|
||||
function Notes({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
|
||||
if (card.mode === 'unlink' || card.mode === 'move') {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
|
||||
}
|
||||
if (card.mode === 'order' && card.photos.length > 0) {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
|
||||
}
|
||||
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
if (GONE.has(item.state)) return null;
|
||||
return (
|
||||
<div>
|
||||
<Route card={card} />
|
||||
<Thumbs card={card} />
|
||||
{card.caption && (
|
||||
<span className="mt-1.5 flex">
|
||||
<FactChange
|
||||
icon={MessageSquareText}
|
||||
before={card.caption.before}
|
||||
after={card.caption.after ?? t('assistant.card.common.removed')}
|
||||
tone="info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<Notes card={card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AssistantChangeRow } from '@trek/shared';
|
||||
import {
|
||||
CircleDot,
|
||||
CloudSun,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Luggage,
|
||||
MapPin,
|
||||
Palette,
|
||||
Route,
|
||||
Smile,
|
||||
Sparkles,
|
||||
ThumbsUp,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The pure side of a change of a journal itself: the glyph and name of each
|
||||
* setting a change row names (under the tool's own field names), and how its
|
||||
* value reads, a status by its name, a switch as on or off, a cleared status
|
||||
* override as following the trip dates. The names are the journal's own,
|
||||
* as its settings dialog writes them.
|
||||
*/
|
||||
|
||||
interface FieldLook {
|
||||
Icon: LucideIcon;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const FIELDS: Readonly<Record<string, FieldLook>> = {
|
||||
status: { Icon: CircleDot, labelKey: 'journey.settings.status' },
|
||||
status_override: { Icon: Eye, labelKey: 'assistant.card.journeyHeader.field.statusOverride' },
|
||||
show_verdict: { Icon: ThumbsUp, labelKey: 'journey.settings.showVerdict' },
|
||||
show_mood: { Icon: Smile, labelKey: 'journey.settings.showMood' },
|
||||
show_weather: { Icon: CloudSun, labelKey: 'journey.settings.showWeather' },
|
||||
photo_location: { Icon: MapPin, labelKey: 'journey.settings.photoLocation' },
|
||||
show_trip_tracks: { Icon: Route, labelKey: 'journey.settings.showTripTracks' },
|
||||
auto_prepare_recordings: { Icon: Footprints, labelKey: 'journey.settings.autoPrepare' },
|
||||
cover_gradient: { Icon: Palette, labelKey: 'assistant.card.journeyHeader.field.coverGradient' },
|
||||
trip: { Icon: Luggage, labelKey: 'assistant.card.journeyHeader.field.trip' },
|
||||
suggestions: { Icon: Sparkles, labelKey: 'assistant.card.journeyHeader.field.suggestions' },
|
||||
hide_skeletons: { Icon: EyeOff, labelKey: 'journey.skeletons.hide' },
|
||||
};
|
||||
|
||||
const STATUSES: Readonly<Record<string, string>> = {
|
||||
draft: 'journey.status.draft',
|
||||
active: 'journey.status.active',
|
||||
completed: 'journey.status.completed',
|
||||
archived: 'journey.status.archived',
|
||||
live: 'journey.frontpage.live',
|
||||
};
|
||||
|
||||
const FLAG_ON = '✓';
|
||||
const FLAG_OFF = '✗';
|
||||
|
||||
function own<T>(table: Readonly<Record<string, T>>, key: string | null | undefined): T | null {
|
||||
if (!key || !Object.prototype.hasOwnProperty.call(table, key)) return null;
|
||||
return table[key] ?? null;
|
||||
}
|
||||
|
||||
/** The look of a setting a row names; null for a field the card has no name for. */
|
||||
export function headerFieldOf(field: string | undefined): FieldLook | null {
|
||||
return own(FIELDS, field);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a value of a setting reads by, or null for one that reads as it is
|
||||
* (a name, a count). A cleared status override follows the trip dates again.
|
||||
*/
|
||||
export function headerValueKey(field: string | undefined, value: string | null): string | null {
|
||||
if (value === FLAG_ON) return 'settings.on';
|
||||
if (value === FLAG_OFF) return 'settings.off';
|
||||
if (field === 'status_override' && !value) return 'journey.settings.statusAuto';
|
||||
if (field === 'status' || field === 'status_override') return own(STATUSES, value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The setting rows of a change, the trips aside: the card draws those as its own chips. */
|
||||
export function settingRows(rows: readonly AssistantChangeRow[]): AssistantChangeRow[] {
|
||||
return rows.filter((row) => row.labelKey === 'field' && row.field !== 'trip' && headerFieldOf(row.field) !== null);
|
||||
}
|
||||
@@ -1,4 +1,22 @@
|
||||
import { BookOpen, BookOpenText, BookPlus, BookX } from 'lucide-react';
|
||||
import {
|
||||
ArrowDownUp,
|
||||
ArrowRightLeft,
|
||||
BookCheck,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
BookPlus,
|
||||
BookX,
|
||||
Eye,
|
||||
ImageMinus,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Link,
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Type,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { PackLook } from '../cardTypes';
|
||||
import { journalTarget } from './journeyCardModel';
|
||||
@@ -17,6 +35,34 @@ export const JOURNEY_LOOK = {
|
||||
addJournalEntry: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.addJournalEntry' },
|
||||
updateJournalEntry: { Icon: BookOpenText, tone: 'info', labelKey: 'assistant.change.verb.updateJournalEntry' },
|
||||
removeJournalEntry: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.removeJournalEntry' },
|
||||
reorderJournalEntries: {
|
||||
Icon: ArrowDownUp,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.reorderJournalEntries',
|
||||
},
|
||||
createJournal: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.createJournal' },
|
||||
renameJournal: { Icon: Type, tone: 'info', labelKey: 'assistant.change.verb.renameJournal' },
|
||||
setJournalStatus: { Icon: BookCheck, tone: 'info', labelKey: 'assistant.change.verb.setJournalStatus' },
|
||||
updateJournalSettings: { Icon: Settings2, tone: 'info', labelKey: 'assistant.change.verb.updateJournalSettings' },
|
||||
deleteJournal: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.deleteJournal' },
|
||||
linkJournalTrip: { Icon: Link, tone: 'success', labelKey: 'assistant.change.verb.linkJournalTrip' },
|
||||
unlinkJournalTrip: { Icon: Unlink, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalTrip' },
|
||||
restoreJournalSuggestions: {
|
||||
Icon: RotateCcw,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
|
||||
},
|
||||
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
|
||||
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
|
||||
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
|
||||
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
|
||||
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
|
||||
captionJournalPhoto: {
|
||||
Icon: MessageSquareText,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.captionJournalPhoto',
|
||||
},
|
||||
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
|
||||
},
|
||||
tools: {
|
||||
create_journey: { labelKey: 'assistant.tool.create_journey' },
|
||||
@@ -35,10 +81,21 @@ export const JOURNEY_LOOK = {
|
||||
journey_entry: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journey.open',
|
||||
open: (applied, card) =>
|
||||
card?.kind === 'journeyEntries'
|
||||
open: (applied, card) => {
|
||||
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
|
||||
return card?.kind === 'journeyEntries'
|
||||
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
|
||||
: null,
|
||||
: null;
|
||||
},
|
||||
},
|
||||
journey: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journeyHeader.open',
|
||||
open: (applied, card) => {
|
||||
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
|
||||
const journeyId = applied.ids[0] ?? fromCard;
|
||||
return journeyId ? journalTarget(journeyId, null, false) : null;
|
||||
},
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
@@ -59,5 +116,11 @@ export const JOURNEY_LOOK = {
|
||||
return readIds.length > 0 && readIds.every((id) => id !== null && touched.has(id));
|
||||
},
|
||||
},
|
||||
journeyHeader: {
|
||||
entity: () => 'journey',
|
||||
},
|
||||
journeyPhotos: {
|
||||
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
|
||||
},
|
||||
},
|
||||
} satisfies PackLook<'journeyEntries'>;
|
||||
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { CardView } from '../cardTypes';
|
||||
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
|
||||
import JourneyEntriesBlock from './JourneyEntriesBlock';
|
||||
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
|
||||
import { JourneyPhotosBody } from './JourneyPhotosSlots';
|
||||
|
||||
/**
|
||||
* How the kinds of the journeys draw in the chat: the entries a read found
|
||||
* as a block of their own, and a change row of an entry with its photo beside
|
||||
* the subject, its day, time and place and whatever an update changes on the
|
||||
* faint line, and the start of its text under it. This file only maps each
|
||||
* faint line, and the start of its text under it; a change of the journal
|
||||
* itself with its settings on the faint line and its trips under it. This file only maps each
|
||||
* kind to its view: the components live in sibling files, since a file that
|
||||
* exports anything besides components breaks fast refresh, and lint:warnings holds
|
||||
* react-refresh/only-export-components at zero for a new file.
|
||||
@@ -18,4 +21,11 @@ export const JOURNEY_VIEWS = {
|
||||
Facts: JourneyEntryFacts,
|
||||
Body: JourneyEntryBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries']: CardView<K> };
|
||||
journeyHeader: {
|
||||
Facts: JourneyHeaderFacts,
|
||||
Body: JourneyHeaderBody,
|
||||
},
|
||||
journeyPhotos: {
|
||||
Body: JourneyPhotosBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* FE-AI-HOME-001 to FE-AI-HOME-004
|
||||
* FE-AI-HOME-001 to FE-AI-HOME-006
|
||||
*
|
||||
* The home chat's shared hooks: its key and name, the way into one trip's own
|
||||
* chat with the question waiting there (a question already typed there
|
||||
* stays), the way in from the pages outside a trip, and the questions the
|
||||
* empty home chat offers.
|
||||
* empty home chat offers, and the journal on screen as a chat.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useJourneyStore } from '../../store/journeyStore';
|
||||
import { HOME_SUGGESTIONS } from './assistantModel';
|
||||
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
|
||||
import { chatPagePath, useAssistantHome, useAssistantHomeLauncher, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
@@ -100,3 +101,29 @@ describe('the empty home chat', () => {
|
||||
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
function RouteJournal() {
|
||||
const chat = useRouteJournalChat();
|
||||
return <p>{chat ? `journal ${chat.id} ${chat.title}` : 'no journal'}</p>;
|
||||
}
|
||||
|
||||
describe('the journal on screen', () => {
|
||||
it("FE-AI-HOME-005: a journey's page is its journal's chat, under the journey's key, but not in its Studio", () => {
|
||||
useJourneyStore.setState({ current: { id: 12, title: 'Japan 2026' } as never });
|
||||
render(<RouteJournal />, { initialEntries: ['/journey/12'] });
|
||||
expect(screen.getByText('journal -12 Japan 2026')).toBeInTheDocument();
|
||||
render(<RouteJournal />, { initialEntries: ['/journey/12/studio'] });
|
||||
expect(screen.getAllByText('no journal')).toHaveLength(1);
|
||||
render(<RouteJournal />, { initialEntries: ['/journey'] });
|
||||
expect(screen.getAllByText('no journal')).toHaveLength(2);
|
||||
// Another journey loaded: the key is the route's, the title waits for its own.
|
||||
render(<RouteJournal />, { initialEntries: ['/journey/13'] });
|
||||
expect(screen.getByText(/^journal -13\s*$/)).toBeInTheDocument();
|
||||
useJourneyStore.setState({ current: null });
|
||||
});
|
||||
|
||||
it('FE-AI-HOME-006: a chat switched to from home opens on its own page', () => {
|
||||
expect(chatPagePath({ id: 5, title: 'Kyoto' })).toBe('/trips/5');
|
||||
expect(chatPagePath({ id: -12, title: 'Japan 2026' })).toBe('/journey/12');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMatch } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { assistantChatKey, assistantScopeOf, HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useJourneyStore } from '../../store/journeyStore';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
|
||||
/**
|
||||
@@ -42,3 +44,27 @@ export function useAssistantHomeLauncher() {
|
||||
const open = useAssistantStore((s) => s.open);
|
||||
return { available, open };
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal on screen as a chat: on a journey's own page the journey's
|
||||
* conversation (the one TREK Studio holds too), under its key and with the
|
||||
* journey's title once the page has loaded it; null elsewhere and in the
|
||||
* Studio, whose column holds that chat itself.
|
||||
*/
|
||||
export function useRouteJournalChat(): AssistantChatTrip | null {
|
||||
const match = useMatch('/journey/:id/*');
|
||||
const inStudio = useMatch('/journey/:id/studio/*') !== null;
|
||||
const id = Number(match?.params.id);
|
||||
const journeyId = !inStudio && Number.isInteger(id) && id > 0 ? id : null;
|
||||
const title = useJourneyStore((s) => (s.current && s.current.id === journeyId ? s.current.title : ''));
|
||||
return useMemo(
|
||||
() => (journeyId === null ? null : { id: assistantChatKey({ kind: 'journey', journeyId }), title }),
|
||||
[journeyId, title]
|
||||
);
|
||||
}
|
||||
|
||||
/** The page a chat switched to from the home chat lives on: a journey's for its key, a trip's otherwise. */
|
||||
export function chatPagePath(chat: AssistantChatTrip): string {
|
||||
const scope = assistantScopeOf(chat.id);
|
||||
return scope.kind === 'journey' ? `/journey/${scope.journeyId}` : `/trips/${chat.id}`;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,17 @@ export function ChatBubble({
|
||||
>
|
||||
{reply.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{/* Wrapped and cut after two lines: a quote kept on one line set the bubble's width and pushed it out of the chat. */}
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.8,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{reply.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('the Collab pieces', () => {
|
||||
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
|
||||
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||
expect(screen.getByText('Train?')).toBeInTheDocument();
|
||||
// A long quote wraps instead of setting the bubble's width (it pushed the bubble out of the chat).
|
||||
expect(screen.getByText('Train?').getAttribute('style')).toContain('white-space: normal');
|
||||
});
|
||||
|
||||
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import type { JourneyMapGLHandle } from './JourneyMapGL'
|
||||
|
||||
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
|
||||
import { useGlRenderer } from '../Map/glSupport'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import type { MapPhoto } from './journeyPhotoLayer'
|
||||
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||
@@ -56,10 +57,11 @@ function JourneyMapAuto({ ref, ...props }: Props) {
|
||||
const leafletRef = useRef<JourneyMapHandle>(null)
|
||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
|
||||
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't supplied a
|
||||
// token yet (MapLibre/OpenFreeMap is tokenless), or when the browser has no WebGL2.
|
||||
const glRenderer = useGlRenderer(provider, token)
|
||||
const useGL = glRenderer !== null
|
||||
const glProvider = glRenderer ?? 'mapbox-gl'
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||
|
||||
@@ -107,7 +107,8 @@ const glModule = vi.hoisted(() => () => ({
|
||||
}))
|
||||
|
||||
vi.mock('mapbox-gl', glModule)
|
||||
vi.mock('maplibre-gl', glModule)
|
||||
// maplibre-gl 6 has no default export: the same fakes, as named exports.
|
||||
vi.mock('maplibre-gl', () => glModule().default)
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
@@ -121,7 +122,7 @@ vi.mock('../Map/mapboxSetup', () => ({
|
||||
|
||||
import React from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
@@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import { buildGlMap } from '../Map/glSupport'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
@@ -360,11 +361,13 @@ function JourneyMapGL(
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
// Null when the GPU refused WebGL2: JourneyMapAuto has switched to Leaflet by then.
|
||||
const map = buildGlMap(() => new gl.Map(mapOptions as any))
|
||||
if (!map) return
|
||||
mapRef.current = map
|
||||
marksFramedRef.current = false
|
||||
entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-015
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -245,4 +245,15 @@ describe('JourneyShareSection', () => {
|
||||
expect(plan).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-JRN-SHARE-015: a filled accent button sets its words in the colour made for it, not in the accent itself', async () => {
|
||||
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
|
||||
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||
|
||||
const filled = [await screen.findByRole('button', { name: 'Timeline' }), screen.getByRole('button', { name: 'Copy' })]
|
||||
for (const button of filled) {
|
||||
expect(button.className).toContain('text-accent-text')
|
||||
expect(button.className).not.toContain('text-accent-on')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
<span className="flex-1 text-caption text-content-muted truncate">{shareUrl}</span>
|
||||
<button type="button"
|
||||
onClick={copyLink}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-on text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-text text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
onClick={() => togglePerm(key)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-body font-medium transition-all ${
|
||||
link[key]
|
||||
? 'border-accent bg-accent text-accent-on'
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge text-content-muted hover:border-content-faint'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
import NotificationDayList from '../Notifications/NotificationDayList'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
@@ -73,9 +73,9 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
insetInlineEnd: 8,
|
||||
width: 380,
|
||||
width: 400,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -119,7 +119,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
|
||||
<div className="overflow-y-auto flex-1 pb-1 border-t border-edge-secondary">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||
@@ -133,9 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
||||
))
|
||||
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
|
||||
import { useGlRenderer } from './glSupport'
|
||||
import { useRoadtripHazards } from './useRoadtripHazards'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
|
||||
@@ -28,11 +29,10 @@ export function MapViewAuto(props: MapViewAutoProps) {
|
||||
const mapProps = { ...props, hazards: hazards.feed?.hazards } as MapViewProps
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
// so trip planner never shows an empty map due to a missing token.
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
|
||||
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
|
||||
: null
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
|
||||
// browser has no WebGL2 (both GL engines need it), so the trip planner never
|
||||
// shows an empty map or one that fails to start.
|
||||
const glProvider = useGlRenderer(provider, token)
|
||||
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
|
||||
// maplibre-gl out of each other's downloads.
|
||||
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The trip map without WebGL2 (FE-COMP-MAPAUTO-WEBGL-*).
|
||||
*
|
||||
* A browser that cannot give a GL engine its WebGL2 context sees the Leaflet map
|
||||
* with the user's tiles, never a map that fails to start: up front when the probe
|
||||
* says no, and the moment the GL map's own constructor is refused when it said yes.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetWebGLProbe } from '../../utils/webgl';
|
||||
import { buildGlMap } from './glSupport';
|
||||
import { MapViewAuto } from './MapViewAuto';
|
||||
|
||||
const gl = vi.hoisted(() => ({ refuse: false }));
|
||||
|
||||
vi.mock('./MapView', () => ({
|
||||
MapView: () => <div data-testid="leaflet-map" />,
|
||||
}));
|
||||
|
||||
/** Stands in for MapViewGL: builds its map the way the real one does, through buildGlMap. */
|
||||
function FakeGlMap() {
|
||||
useEffect(() => {
|
||||
buildGlMap(() => {
|
||||
if (!gl.refuse) return {};
|
||||
const err = new Error('WebGL2 is required to display this map.');
|
||||
err.name = 'GPUInitializationError';
|
||||
throw err;
|
||||
});
|
||||
}, []);
|
||||
return <div data-testid="gl-map" />;
|
||||
}
|
||||
|
||||
vi.mock('./glLazy', () => ({ MapViewGLMaplibre: FakeGlMap, MapViewGLMapbox: FakeGlMap }));
|
||||
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
|
||||
|
||||
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
function browserWithWebGL2(available: boolean) {
|
||||
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
|
||||
available && type === 'webgl2' ? ctx : null
|
||||
) as unknown as typeof realGetContext;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
resetWebGLProbe();
|
||||
gl.refuse = false;
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', mapbox_access_token: '' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = realGetContext;
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
describe('MapViewAuto without WebGL2', () => {
|
||||
it('FE-COMP-MAPAUTO-WEBGL-001: draws the MapLibre map where WebGL2 is there', async () => {
|
||||
browserWithWebGL2(true);
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
expect(await screen.findByTestId('gl-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('leaflet-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-WEBGL-002: draws the Leaflet map when the browser has no WebGL2', () => {
|
||||
browserWithWebGL2(false);
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
expect(screen.getByTestId('leaflet-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-WEBGL-003: falls back to Leaflet when the GPU refuses the context the probe promised', async () => {
|
||||
browserWithWebGL2(true);
|
||||
gl.refuse = true;
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
expect(await screen.findByTestId('leaflet-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
@@ -164,22 +164,21 @@ vi.mock('mapbox-gl', () => ({
|
||||
}));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
|
||||
// maplibre-gl 6 has no default export, so the fakes are named exports.
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
},
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
@@ -156,22 +156,21 @@ vi.mock('mapbox-gl', () => ({
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
|
||||
// maplibre-gl 6 has no default export, so the fakes are named exports.
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts)
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create()
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup
|
||||
}),
|
||||
},
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts)
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create()
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup
|
||||
}),
|
||||
}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
@@ -352,7 +351,7 @@ describe('MapViewGL', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const maplibregl = (await import('maplibre-gl')).default
|
||||
const maplibregl = await import('maplibre-gl')
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
@@ -373,7 +372,7 @@ describe('MapViewGL', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-014: MapLibre maps disable the around-center mouse rotate (#1545)', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const maplibregl = (await import('maplibre-gl')).default
|
||||
const maplibregl = await import('maplibre-gl')
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import { buildGlMap } from './glSupport'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -1104,13 +1105,12 @@ export function MapViewGL({
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's mouse-rotate inverts its sign at a mid-screen line it gets by
|
||||
// re-projecting the map center — a line that drifts with the bearing, so a
|
||||
// right-button drag near mid-screen ping-pongs instead of rotating (#1545).
|
||||
// aroundCenter: false restores the plain dx-based rotate mapbox-gl uses.
|
||||
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
// Null when the GPU refused WebGL2: MapViewAuto has switched to Leaflet by then.
|
||||
const map = buildGlMap(() => new gl.Map(mapOptions as any))
|
||||
if (!map) return
|
||||
mapRef.current = map
|
||||
popupRef.current = new gl.Popup({
|
||||
closeButton: false,
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
RASTER_FALLBACK_TILE_URL,
|
||||
attributionForTile,
|
||||
} from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
|
||||
import { isGpuInitFailure } from './glSupport'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
@@ -96,6 +97,8 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
|
||||
layer.addTo(map)
|
||||
} catch (err) {
|
||||
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
|
||||
// The GPU, not a context budget: the GL maps elsewhere in the app switch to Leaflet too.
|
||||
if (isGpuInitFailure(err)) reportWebGLFailure()
|
||||
detachBasemapLayer(layer)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -11,9 +10,22 @@ import { registerRtlTextPlugin } from '../rtlText'
|
||||
* Popup, LngLatBounds — which is why the components take the engine as a prop and
|
||||
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
|
||||
* on the provider (projection, accessToken, aroundCenter).
|
||||
*
|
||||
* MapLibre 6 ships as ES modules only and has no default export any more, so the
|
||||
* namespace is what goes out as this module's default. It is a frozen module
|
||||
* namespace: nothing may assign to it, which is why the accessToken writes stay
|
||||
* behind their mapbox-only guard.
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
|
||||
// MapLibre 6 finds its worker next to its own module, which inside a bundle is a
|
||||
// chunk in /assets/ with no worker beside it. Vite builds the worker into its own
|
||||
// file and hands back the URL; set before any Map is constructed, the
|
||||
// maplibre-gl-leaflet bridge included, since it only ever reaches the engine
|
||||
// together with this module.
|
||||
maplibregl.setWorkerUrl(workerUrl)
|
||||
|
||||
// No RTL text plugin here, unlike mapbox. MapLibre 6.9 shapes Arabic and orders
|
||||
// bidirectional text itself; the old plugin would replace that implementation, and
|
||||
// a v6 worker can only load the plugin's UMD build through eval.
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* The two lines this fix actually adds. Everything else is a helper: the guard in
|
||||
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting either registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green.
|
||||
* What each engine module does on import. Everything else is a helper: the guard
|
||||
* in rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting the mapbox registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green, and so would
|
||||
* a maplibre engine that forgot its worker URL.
|
||||
*
|
||||
* The engines are mocked because importing them for real builds a worker pool
|
||||
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
|
||||
*/
|
||||
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
|
||||
const { mapboxSet, maplibreSet, maplibreWorker } = vi.hoisted(() => ({
|
||||
mapboxSet: vi.fn(),
|
||||
maplibreSet: vi.fn(),
|
||||
maplibreWorker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ setRTLTextPlugin: maplibreSet, setWorkerUrl: maplibreWorker }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url', () => ({ default: '/assets/maplibre-worker.js' }));
|
||||
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mapboxSet.mockReset();
|
||||
maplibreSet.mockReset();
|
||||
maplibreWorker.mockReset();
|
||||
});
|
||||
|
||||
describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
@@ -37,13 +41,12 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers no plugin', async () => {
|
||||
await import('./maplibre');
|
||||
|
||||
expect(maplibreSet).toHaveBeenCalledTimes(1);
|
||||
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
|
||||
// the call stays with each engine instead of going through one wrapper.
|
||||
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
|
||||
// MapLibre 6.9 shapes right-to-left text itself. The plugin would replace
|
||||
// that, and a v6 worker could only run its UMD build through eval.
|
||||
expect(maplibreSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
|
||||
@@ -54,13 +57,13 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
expect(engine.default).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
|
||||
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
|
||||
|
||||
it('FE-MAP-RTL-004: importing the maplibre engine points it at the bundled worker', async () => {
|
||||
const engine = await import('./maplibre');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
// Give the rejection a tick to go unhandled if nothing caught it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// MapLibre 6 looks for its worker next to its own module, which in a bundle
|
||||
// is a chunk with no worker beside it. Without this no map draws a tile.
|
||||
expect(maplibreWorker).toHaveBeenCalledTimes(1);
|
||||
expect(maplibreWorker).toHaveBeenCalledWith('/assets/maplibre-worker.js');
|
||||
expect(engine.default.setWorkerUrl).toBe(maplibreWorker);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Whether a map may be drawn with a GL engine (FE-MAP-GLSUP-*).
|
||||
*
|
||||
* MapLibre 6 and Mapbox GL 3 throw out of their Map constructor without WebGL2.
|
||||
* What is under test is the one decision every GL map goes through: the user's
|
||||
* provider when it can run here, Leaflet otherwise, and Leaflet from the moment a
|
||||
* GPU that claimed WebGL2 refuses the context after all.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hasWebGL, resetWebGLProbe } from '../../utils/webgl';
|
||||
import { buildGlMap, isGpuInitFailure, pickGlRenderer, useGlRenderer, useWebGL2 } from './glSupport';
|
||||
|
||||
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
/** A browser whose canvas hands out a webgl2 context, or none. */
|
||||
function browserWithWebGL2(available: boolean) {
|
||||
const ctx = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) };
|
||||
const getContext = vi.fn((type: string) => (available && type === 'webgl2' ? ctx : null));
|
||||
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext;
|
||||
return getContext;
|
||||
}
|
||||
|
||||
function gpuError(): Error {
|
||||
const err = new Error('WebGL2 is required to display this map.');
|
||||
err.name = 'GPUInitializationError';
|
||||
return err;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = realGetContext;
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
describe('pickGlRenderer', () => {
|
||||
it('FE-MAP-GLSUP-001: keeps the GL provider the user picked when WebGL2 is there', () => {
|
||||
expect(pickGlRenderer('maplibre-gl', '', () => true)).toBe('maplibre-gl');
|
||||
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => true)).toBe('mapbox-gl');
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-002: draws with Leaflet when the browser has no WebGL2', () => {
|
||||
expect(pickGlRenderer('maplibre-gl', '', () => false)).toBeNull();
|
||||
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => false)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-003: Mapbox without a token stays on Leaflet, MapLibre needs none', () => {
|
||||
expect(pickGlRenderer('mapbox-gl', '', () => true)).toBeNull();
|
||||
expect(pickGlRenderer('maplibre-gl', null, () => true)).toBe('maplibre-gl');
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-004: a Leaflet user never pays for the probe', () => {
|
||||
const webgl2 = vi.fn(() => true);
|
||||
|
||||
expect(pickGlRenderer('leaflet', 'pk.token', webgl2)).toBeNull();
|
||||
expect(pickGlRenderer(undefined, undefined, webgl2)).toBeNull();
|
||||
expect(webgl2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGpuInitFailure', () => {
|
||||
it('FE-MAP-GLSUP-005: knows both engines’ way of saying the GPU refused', () => {
|
||||
expect(isGpuInitFailure(gpuError())).toBe(true);
|
||||
expect(isGpuInitFailure(new Error('Failed to initialize WebGL.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-006: anything else is not a GPU failure', () => {
|
||||
expect(isGpuInitFailure(new TypeError('container is null'))).toBe(false);
|
||||
expect(isGpuInitFailure('Failed to initialize WebGL')).toBe(false);
|
||||
expect(isGpuInitFailure(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGlMap', () => {
|
||||
it('FE-MAP-GLSUP-007: hands back the map the engine built', () => {
|
||||
const map = { remove: vi.fn() };
|
||||
|
||||
expect(buildGlMap(() => map)).toBe(map);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-008: a GPU that claimed WebGL2 and refused it gives null and turns the answer to no', () => {
|
||||
browserWithWebGL2(true);
|
||||
expect(hasWebGL()).toBe(true);
|
||||
|
||||
expect(
|
||||
buildGlMap(() => {
|
||||
throw gpuError();
|
||||
})
|
||||
).toBeNull();
|
||||
expect(hasWebGL()).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-009: any other error is a bug and is thrown on', () => {
|
||||
browserWithWebGL2(true);
|
||||
|
||||
expect(() =>
|
||||
buildGlMap(() => {
|
||||
throw new TypeError('style is undefined');
|
||||
})
|
||||
).toThrow('style is undefined');
|
||||
expect(hasWebGL()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGlRenderer', () => {
|
||||
it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => {
|
||||
const getContext = browserWithWebGL2(false);
|
||||
|
||||
const { result } = renderHook(() => useGlRenderer('maplibre-gl', ''));
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
expect(getContext.mock.calls.map((call) => call[0])).toEqual(['webgl2']);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-011: switches every mounted map to Leaflet once a GL map fails to build', () => {
|
||||
browserWithWebGL2(true);
|
||||
const trip = renderHook(() => useGlRenderer('maplibre-gl', ''));
|
||||
const terrain = renderHook(() => useWebGL2());
|
||||
expect(trip.result.current).toBe('maplibre-gl');
|
||||
expect(terrain.result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
buildGlMap(() => {
|
||||
throw gpuError();
|
||||
});
|
||||
});
|
||||
|
||||
expect(trip.result.current).toBeNull();
|
||||
expect(terrain.result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { hasWebGL, reportWebGLFailure, subscribeWebGL, webGLFailures } from '../../utils/webgl';
|
||||
import type { GlMapProvider } from './glProviders';
|
||||
|
||||
/*
|
||||
* Whether a map may be drawn with a GL engine at all, for every place that would
|
||||
* build one: the trip map, the journey maps, the settings preview and the 3D tour.
|
||||
*
|
||||
* MapLibre 6 and Mapbox GL 3 both need WebGL2 and throw out of their Map
|
||||
* constructor without it. A browser that cannot give it gets the Leaflet map with
|
||||
* the user's tiles instead, never a map that fails to start. Like the GL
|
||||
* renderers it guards, this module stays free of both SDKs (`check:gl-split`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The GL engine to draw with, or null for Leaflet: the user's provider, as long
|
||||
* as it can run here. Mapbox also needs a token, MapLibre does not.
|
||||
*/
|
||||
export function pickGlRenderer(
|
||||
provider: string | null | undefined,
|
||||
mapboxToken: string | null | undefined,
|
||||
webgl2: () => boolean = hasWebGL
|
||||
): GlMapProvider | null {
|
||||
const wanted: GlMapProvider | null =
|
||||
provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && mapboxToken ? 'mapbox-gl' : null;
|
||||
// Asked only for a GL provider: a Leaflet user never pays for the probe's context.
|
||||
return wanted && webgl2() ? wanted : null;
|
||||
}
|
||||
|
||||
/** Re-renders the caller when a GL map found out that WebGL2 does not work after all. */
|
||||
function useWebGLFailures(): number {
|
||||
return useSyncExternalStore(subscribeWebGL, webGLFailures, webGLFailures);
|
||||
}
|
||||
|
||||
/** pickGlRenderer for a component, switching to Leaflet the moment a GL map fails to build. */
|
||||
export function useGlRenderer(
|
||||
provider: string | null | undefined,
|
||||
mapboxToken: string | null | undefined
|
||||
): GlMapProvider | null {
|
||||
useWebGLFailures();
|
||||
return pickGlRenderer(provider, mapboxToken);
|
||||
}
|
||||
|
||||
/** Whether 3D (the GL terrain map) can run here, kept current the same way. */
|
||||
export function useWebGL2(): boolean {
|
||||
useWebGLFailures();
|
||||
return hasWebGL();
|
||||
}
|
||||
|
||||
/**
|
||||
* The GPU refusing a context, as either engine reports it from its Map
|
||||
* constructor. Matched on the name and message because the class itself lives in
|
||||
* the maplibre chunk, and Mapbox throws a plain Error.
|
||||
*/
|
||||
export function isGpuInitFailure(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
return err.name === 'GPUInitializationError' || /failed to initialize webgl/i.test(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a GL map, the second line of defence behind the probe: a GPU that
|
||||
* claims WebGL2 and still fails is reported, so every map switches to Leaflet,
|
||||
* and the caller gets null instead of a throw. Anything else is a bug and is
|
||||
* thrown on.
|
||||
*/
|
||||
export function buildGlMap<T>(construct: () => T): T | null {
|
||||
try {
|
||||
return construct();
|
||||
} catch (err) {
|
||||
if (!isGpuInitFailure(err)) throw err;
|
||||
reportWebGLFailure();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,18 @@
|
||||
* this because their labels are drawn server-side, which is why it only surfaced
|
||||
* once the default basemap became a vector style.
|
||||
*
|
||||
* Both engines expose the fix as an optional plugin and neither loads it by
|
||||
* default. Registering is global to the engine, so it happens once per engine
|
||||
* module rather than at each map's construction site.
|
||||
* Mapbox GL exposes the fix as an optional plugin and does not load it by
|
||||
* default. Registering is global to the engine, so it happens once in the engine
|
||||
* module rather than at each map's construction site. MapLibre needed the same
|
||||
* plugin up to version 5; since 6.9 it shapes and reorders the text itself, and
|
||||
* its engine module registers nothing.
|
||||
*
|
||||
* The call itself stays with the caller: this is one of the places the two engines
|
||||
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
|
||||
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
|
||||
* module passes its own invocation rather than a shared wrapper guessing an arity.
|
||||
* The call itself stays with the caller: Mapbox GL 3 takes
|
||||
* `(url, callback, deferred)` and returns nothing, while MapLibre took
|
||||
* `(url, lazy)` and returned a promise, so the guard below accepts either rather
|
||||
* than guessing an arity.
|
||||
*
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it made MapLibre
|
||||
* build its worker pool, which is an eager side effect for a module that is
|
||||
* imported for its default export, and throws outright under jsdom. Registering
|
||||
* twice is reported — by a throw or a rejected promise depending on the engine —
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
import React from 'react'
|
||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
||||
}
|
||||
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The round tool beside a notification, quiet until it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
|
||||
/** The quiet tool beside a notification, forward only when it is pointed at. */
|
||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-secondary focus-visible:bg-surface-secondary focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a pill under the words. */
|
||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
||||
/** An answer or a way on, as a button under the words. */
|
||||
const ACTION = 'inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-caption font-semibold transition-colors'
|
||||
|
||||
/** Under an hour it reads "5m"; after that the clock time, since the list is headed by its day. */
|
||||
function timeOf(value: string, locale: string, justNow: string): string {
|
||||
const date = createdAt(value)
|
||||
if (Date.now() - date.getTime() < 3_600_000) return compactTime(date.toISOString(), justNow)
|
||||
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
|
||||
}
|
||||
|
||||
/** The tile on the start side: the kind's symbol on its colour, and who sent it as a small face on its corner. */
|
||||
function KindTile({ notification }: { notification: InAppNotification }) {
|
||||
const { icon: Icon, tone } = KIND_LOOK[kindOf(notification.title_key)]
|
||||
const initial = notification.sender_username?.charAt(0).toUpperCase()
|
||||
return (
|
||||
<span className="relative block self-start flex-shrink-0">
|
||||
<span className={`grid place-items-center w-10 h-10 rounded-xl ${tone}`}>
|
||||
<Icon className="w-[18px] h-[18px]" aria-hidden />
|
||||
</span>
|
||||
{notification.sender_avatar ? (
|
||||
<img src={notification.sender_avatar} alt="" className="absolute -bottom-1 -end-1 w-5 h-5 rounded-full object-cover ring-2 ring-surface-card" />
|
||||
) : initial ? (
|
||||
<span aria-hidden className="absolute -bottom-1 -end-1 grid place-items-center w-5 h-5 rounded-full text-caption leading-none font-bold bg-accent text-accent-text ring-2 ring-surface-card">
|
||||
{initial}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification, as a card in the list: who sent it, what it says, and what
|
||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
||||
* ground; the tools to mark and delete come forward when the card is pointed
|
||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
||||
* One notification: a tile that says at a glance what it is about and who sent
|
||||
* it, the message itself, and what can be done with it. Unread ones are set in
|
||||
* bold with a dot in the accent; marking and deleting come forward when the row
|
||||
* is pointed at or focused, so a list reads as messages rather than as buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -32,112 +56,96 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
const titleText = t(notification.title_key, notification.title_params)
|
||||
const bodyText = t(notification.text_key, notification.text_params)
|
||||
const hasUnknownTitle = titleText === notification.title_key
|
||||
const hasUnknownBody = bodyText === notification.text_key
|
||||
const unread = !notification.is_read
|
||||
const answered = notification.response !== null
|
||||
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
|
||||
const asks = notification.type === 'boolean' && yesKey && noKey
|
||||
const leads = notification.type === 'navigate' && goKey && notification.navigate_target
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
|
||||
>
|
||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
<div className="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
|
||||
<KindTile notification={notification} />
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="relative flex-shrink-0 h-7 flex items-center">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
|
||||
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
|
||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||
{timeOf(notification.created_at, locale, t('common.justNow'))}
|
||||
</span>
|
||||
{unread && (
|
||||
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
|
||||
)}
|
||||
<div className="absolute end-0 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{notification.is_read ? (
|
||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
title={t('notifications.delete')}
|
||||
aria-label={t('notifications.delete')}
|
||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<div className="flex gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-accent text-accent-text'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(notification.positive_text_key)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || answered}
|
||||
className={`${PILL} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger text-white'
|
||||
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(notification.negative_text_key)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigate action */}
|
||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
||||
{asks && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
onClick={() => handleRespond('positive')}
|
||||
disabled={responding || answered}
|
||||
className={`${ACTION} ${
|
||||
notification.response === 'positive'
|
||||
? 'bg-success-soft text-success'
|
||||
: answered
|
||||
? 'border border-edge text-content-faint'
|
||||
: 'bg-accent text-accent-text hover:bg-accent-hover'
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{t(notification.navigate_text_key)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(yesKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRespond('negative')}
|
||||
disabled={responding || answered}
|
||||
className={`${ACTION} ${
|
||||
notification.response === 'negative'
|
||||
? 'bg-danger-soft text-danger'
|
||||
: answered
|
||||
? 'border border-edge text-content-faint'
|
||||
: 'border border-edge text-content hover:bg-surface-secondary'
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t(noKey)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leads && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
className={`${ACTION} mt-3 border border-edge text-content hover:bg-accent hover:text-accent-text hover:border-transparent focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||
>
|
||||
{t(goKey)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import InAppNotificationItem from './InAppNotificationItem';
|
||||
import { groupByDay } from './notificationModel';
|
||||
|
||||
interface NotificationDayListProps {
|
||||
notifications: InAppNotification[];
|
||||
/** Closes the bell's panel after a notification led somewhere. */
|
||||
onClose?: () => void;
|
||||
/** The bell's panel: headings stick to its top and the days need no cards of their own. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notifications cut into days, each under its heading (Today, Yesterday,
|
||||
* a weekday, then the date). On the page every day is a card of its own; in
|
||||
* the bell's panel the headings stay at the top while the day scrolls by.
|
||||
*/
|
||||
export default function NotificationDayList({
|
||||
notifications,
|
||||
onClose,
|
||||
compact = false,
|
||||
}: NotificationDayListProps): React.ReactElement {
|
||||
const { locale } = useTranslation();
|
||||
const groups = useMemo(() => groupByDay(notifications, locale), [notifications, locale]);
|
||||
|
||||
return (
|
||||
<div className={compact ? '' : 'flex flex-col gap-6'}>
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} aria-label={group.label}>
|
||||
<h2
|
||||
className={
|
||||
compact
|
||||
? 'sticky top-0 z-10 bg-surface-card px-4 pb-1.5 pt-3 text-caption font-semibold text-content-muted'
|
||||
: 'mb-2 px-1 text-caption font-semibold text-content-muted'
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
</h2>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'divide-y divide-edge-secondary'
|
||||
: 'divide-y divide-edge-secondary overflow-hidden rounded-2xl border border-edge bg-surface-card'
|
||||
}
|
||||
>
|
||||
{group.items.map((n) => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
|
||||
*
|
||||
* What a notification's tile shows, how the server's time is read, and how the
|
||||
* list is cut into days under headings in the reader's language.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import NotificationDayList from './NotificationDayList';
|
||||
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
|
||||
|
||||
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
|
||||
id,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: 1,
|
||||
sender_id: null,
|
||||
sender_username: null,
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key,
|
||||
title_params: {},
|
||||
text_key: 'notifications.empty',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: false,
|
||||
created_at,
|
||||
});
|
||||
|
||||
const NOW = new Date(2026, 9, 24, 15, 0);
|
||||
const at = (days: number, hour = 10) => new Date(2026, 9, 24 - days, hour, 0);
|
||||
|
||||
describe('notificationModel', () => {
|
||||
it('FE-NOTIF-MODEL-001: every known title has its kind, anything else is the plain bell', () => {
|
||||
expect(kindOf('notif.collab_message.title')).toBe('message');
|
||||
expect(kindOf('notif.replica_failure.title')).toBe('alert');
|
||||
expect(kindOf('plugin.something.title')).toBe('other');
|
||||
for (const look of Object.values(KIND_LOOK)) expect(look.tone).toMatch(/^bg-/);
|
||||
});
|
||||
|
||||
it("FE-NOTIF-MODEL-002: the server's UTC text is read as UTC, an ISO string as it is", () => {
|
||||
expect(createdAt('2026-10-24 08:30:00').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||
expect(createdAt('2026-10-24T08:30:00.000Z').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-003: today and yesterday by name, the week by weekday, older by date', () => {
|
||||
expect(dayLabel(at(0), 'en', NOW)).toBe('Today');
|
||||
expect(dayLabel(at(1), 'en', NOW)).toBe('Yesterday');
|
||||
expect(dayLabel(at(3), 'en', NOW)).toBe('Wednesday');
|
||||
expect(dayLabel(at(20), 'en', NOW)).toBe('October 4');
|
||||
expect(dayLabel(new Date(2025, 11, 31), 'en', NOW)).toBe('December 31, 2025');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-004: the headings follow the language', () => {
|
||||
expect(dayLabel(at(0), 'de', NOW)).toBe('Heute');
|
||||
expect(dayLabel(at(1), 'de', NOW)).toBe('Gestern');
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-005: the list is cut into days in the order it came', () => {
|
||||
const groups = groupByDay(
|
||||
[note(1, at(0, 12).toISOString()), note(2, at(0, 9).toISOString()), note(3, at(2).toISOString())],
|
||||
'en',
|
||||
NOW
|
||||
);
|
||||
expect(groups.map((g) => [g.label, g.items.map((n) => n.id)])).toEqual([
|
||||
['Today', [1, 2]],
|
||||
['Thursday', [3]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-006: an empty list has no days', () => {
|
||||
expect(groupByDay([], 'en', NOW)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationDayList', () => {
|
||||
it('FE-NOTIF-MODEL-007: each day is a section under its heading', () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12);
|
||||
render(<NotificationDayList notifications={[note(1, now.toISOString()), note(2, yesterday.toISOString())]} />);
|
||||
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Yesterday' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Backpack,
|
||||
Bell,
|
||||
BookmarkPlus,
|
||||
CalendarDays,
|
||||
Images,
|
||||
ListChecks,
|
||||
MessageCircle,
|
||||
Mountain,
|
||||
Plane,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
|
||||
/** What a notification is about, read from its title key: it picks the symbol and colour of its tile. */
|
||||
export type NotificationKind =
|
||||
| 'trip'
|
||||
| 'message'
|
||||
| 'booking'
|
||||
| 'packing'
|
||||
| 'todo'
|
||||
| 'vacay'
|
||||
| 'collection'
|
||||
| 'photos'
|
||||
| 'tour'
|
||||
| 'update'
|
||||
| 'alert'
|
||||
| 'other';
|
||||
|
||||
const KIND_BY_TITLE: Record<string, NotificationKind> = {
|
||||
'notif.trip_invite.title': 'trip',
|
||||
'notif.trip_reminder.title': 'trip',
|
||||
'notif.collab_message.title': 'message',
|
||||
'notif.booking_change.title': 'booking',
|
||||
'notif.packing_tagged.title': 'packing',
|
||||
'notif.todo_due.title': 'todo',
|
||||
'notif.vacay_invite.title': 'vacay',
|
||||
'notif.vacay_share.title': 'vacay',
|
||||
'notif.collection_invite.title': 'collection',
|
||||
'notif.photos_shared.title': 'photos',
|
||||
'notif.wanderer_source_hint.title': 'tour',
|
||||
'notif.paddle_region_requested.title': 'tour',
|
||||
'notif.version_available.title': 'update',
|
||||
'notifications.versionAvailable.title': 'update',
|
||||
'notif.replica_failure.title': 'alert',
|
||||
'notifications.synologySessionCleared.title': 'alert',
|
||||
};
|
||||
|
||||
export function kindOf(titleKey: string): NotificationKind {
|
||||
return KIND_BY_TITLE[titleKey] ?? 'other';
|
||||
}
|
||||
|
||||
/** The symbol of each kind and the tinted tile it sits on. */
|
||||
export const KIND_LOOK: Record<NotificationKind, { icon: LucideIcon; tone: string }> = {
|
||||
trip: { icon: Plane, tone: 'bg-info-soft text-info' },
|
||||
message: { icon: MessageCircle, tone: 'bg-success-soft text-success' },
|
||||
booking: { icon: Ticket, tone: 'bg-warning-soft text-warning' },
|
||||
packing: { icon: Backpack, tone: 'bg-warning-soft text-warning' },
|
||||
todo: { icon: ListChecks, tone: 'bg-success-soft text-success' },
|
||||
vacay: { icon: CalendarDays, tone: 'bg-info-soft text-info' },
|
||||
collection: { icon: BookmarkPlus, tone: 'bg-accent-subtle text-accent' },
|
||||
photos: { icon: Images, tone: 'bg-warning-soft text-warning' },
|
||||
tour: { icon: Mountain, tone: 'bg-success-soft text-success' },
|
||||
update: { icon: Sparkles, tone: 'bg-accent-subtle text-accent' },
|
||||
alert: { icon: TriangleAlert, tone: 'bg-danger-soft text-danger' },
|
||||
other: { icon: Bell, tone: 'bg-surface-secondary text-content-secondary' },
|
||||
};
|
||||
|
||||
/**
|
||||
* When a notification was made. The server writes `YYYY-MM-DD HH:MM:SS` in UTC,
|
||||
* which a bare `new Date()` would read as local time.
|
||||
*/
|
||||
export function createdAt(value: string): Date {
|
||||
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
|
||||
}
|
||||
|
||||
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
|
||||
/**
|
||||
* The heading of the day a notification belongs to, in the reader's language:
|
||||
* today and yesterday by name, the rest of the week by weekday, anything older
|
||||
* by its date (with the year when it is not this one).
|
||||
*/
|
||||
export function dayLabel(date: Date, locale: string, now: Date = new Date()): string {
|
||||
const days = Math.round((dayStart(now) - dayStart(date)) / 86_400_000);
|
||||
if (days <= 1) {
|
||||
const word = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-Math.max(days, 0), 'day');
|
||||
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
||||
}
|
||||
if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export interface NotificationGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
items: InAppNotification[];
|
||||
}
|
||||
|
||||
/** The list cut into days, in the order it came (newest first). */
|
||||
export function groupByDay(list: InAppNotification[], locale: string, now: Date = new Date()): NotificationGroup[] {
|
||||
const groups: NotificationGroup[] = [];
|
||||
for (const n of list) {
|
||||
const date = createdAt(n.created_at);
|
||||
const key = String(dayStart(date));
|
||||
const current = groups[groups.length - 1];
|
||||
if (current?.key === key) current.items.push(n);
|
||||
else groups.push({ key, label: dayLabel(date, locale, now), items: [n] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
|
||||
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn(), reportWebGLFailure: vi.fn() }))
|
||||
|
||||
const { hasWebGL } = await import('../../utils/webgl')
|
||||
const { renderTripMapImage } = await import('./tripMapImage')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildRouteOverlay, wrapSvg } from './tripMapSvg'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { normalizeStyleForProvider } from '../Map/glProviders'
|
||||
import { buildGlMap } from '../Map/glSupport'
|
||||
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
|
||||
import { importChunk } from '../../utils/chunkReload'
|
||||
|
||||
@@ -49,18 +50,20 @@ export async function renderTripMapImage(
|
||||
try {
|
||||
const gl = (await importChunk(() => import('../Map/engines/maplibre'))).default
|
||||
|
||||
container = document.createElement('div')
|
||||
// A const of its own: the map is built inside a closure, where the let's narrowing is gone.
|
||||
const box = document.createElement('div')
|
||||
container = box
|
||||
// Off-screen rather than hidden: MapLibre needs a laid-out box with real
|
||||
// dimensions, and `display:none` gives it a zero-sized canvas.
|
||||
container.style.cssText =
|
||||
box.style.cssText =
|
||||
`position:fixed;left:-10000px;top:0;width:${width}px;height:${height}px;pointer-events:none;`
|
||||
document.body.appendChild(container)
|
||||
document.body.appendChild(box)
|
||||
|
||||
const bounds = new gl.LngLatBounds()
|
||||
for (const [lat, lng] of points) bounds.extend([lng, lat])
|
||||
|
||||
const instance = new gl.Map({
|
||||
container,
|
||||
const instance = buildGlMap(() => new gl.Map({
|
||||
container: box,
|
||||
style: normalizeStyleForProvider('maplibre-gl', opts.style),
|
||||
bounds,
|
||||
fitBoundsOptions: { padding: 42, animate: false },
|
||||
@@ -70,7 +73,9 @@ export async function renderTripMapImage(
|
||||
// MapLibre 5 moved the WebGL flags off the top level into this bag.
|
||||
canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true },
|
||||
fadeDuration: 0,
|
||||
})
|
||||
}))
|
||||
// A GPU that offered WebGL2 and then refused it: reported, so the app's maps follow.
|
||||
if (!instance) return null
|
||||
map = instance
|
||||
|
||||
const ready = await new Promise<boolean>(resolve => {
|
||||
|
||||
@@ -33,12 +33,6 @@ function mcpEndpointRow() {
|
||||
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const LAN_CONNECTOR = {
|
||||
mcpUrl: 'http://192.168.1.20:3000/mcp',
|
||||
deepLink: 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=x',
|
||||
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
||||
};
|
||||
|
||||
function enableAddons(ids: string[]) {
|
||||
seedStore(useAddonStore, {
|
||||
addons: ids.map(id => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
|
||||
@@ -71,7 +65,6 @@ beforeEach(() => {
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
||||
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
|
||||
http.get('/api/integrations/claude-connector', () => HttpResponse.json(LAN_CONNECTOR)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1026,25 +1019,4 @@ describe('IntegrationsTab – failure toasts', () => {
|
||||
render(<IntegrationsTab />);
|
||||
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-049: the Claude card explains why a LAN install cannot be added', async () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
expect(await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /Open in Claude/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-050: the Claude card links a public install into Claude', async () => {
|
||||
server.use(
|
||||
http.get('/api/integrations/claude-connector', () =>
|
||||
HttpResponse.json({ ...LAN_CONNECTOR, mcpUrl: 'https://trip.example.org/mcp' }),
|
||||
),
|
||||
);
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
const link = await screen.findByRole('link', { name: /Open in Claude/ });
|
||||
expect(link).toHaveAttribute('href', LAN_CONNECTOR.deepLink);
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(screen.getByText('https://trip.example.org/mcp')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import DawarichConnectionSection from './DawarichConnectionSection'
|
||||
import LlmConnectionSection from './LlmConnectionSection'
|
||||
import AiConnectionSection from './ai/AiConnectionSection'
|
||||
import ClaudeConnectorCard from './ai/ClaudeConnectorCard'
|
||||
import ApiKeysSection from './ApiKeysSection'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
@@ -45,7 +44,6 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
one should not have to read past a section about AI assistants. */}
|
||||
<ApiKeysSection />
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && <ClaudeConnectorCard />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
||||
<McpTokenModals S={S} />
|
||||
<OAuthClientModals S={S} />
|
||||
|
||||
@@ -33,6 +33,12 @@ vi.mock('./MapboxPreview', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// jsdom has no WebGL2; these cases are about a browser that has it.
|
||||
vi.mock('../../utils/webgl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/webgl')>()),
|
||||
hasWebGL: () => true,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, save: saveMapSettings,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
|
||||
} = useMapSettingsForm()
|
||||
// One chunk per engine — see components/Map/glLazy.tsx.
|
||||
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
|
||||
@@ -318,7 +318,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
)}
|
||||
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
|
||||
{provider !== 'leaflet' ? (
|
||||
{glPreview ? (
|
||||
/* A net of its own: the preview is the one place a user flips providers
|
||||
live, so it is the likeliest chunk to fail — and a broken preview must
|
||||
not take the rest of the settings tab with it. */
|
||||
@@ -363,6 +363,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{webgl2Missing && <SettingsHint>{t('map.webgl2.fallback')}</SettingsHint>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button"
|
||||
|
||||
@@ -32,7 +32,7 @@ const mapboxModule = vi.hoisted(() => ({ accessToken: '', Map: mapboxCtor }));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: mapboxModule }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl', () => ({ default: { Map: maplibreCtor } }));
|
||||
vi.mock('maplibre-gl', () => ({ Map: maplibreCtor }));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
vi.mock('../Map/mapboxSetup', () => ({
|
||||
@@ -45,7 +45,7 @@ vi.mock('../Map/mapboxSetup', () => ({
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup';
|
||||
import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import GlMapPreviewWithEngine from './MapboxPreview';
|
||||
|
||||
// The engine is a prop now, not a module import — that is what keeps mapbox-gl and
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
|
||||
import { buildGlMap } from '../Map/glSupport'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
interface Props {
|
||||
@@ -46,11 +47,13 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
|
||||
antialias: quality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
// Null when the GPU refused WebGL2: the settings form has switched the preview to Leaflet by then.
|
||||
const map = buildGlMap(() => new gl.Map(mapOptions as any))
|
||||
if (!map) return
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
|
||||
@@ -3,8 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { resetAllStores } from '../../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -173,17 +172,11 @@ describe('AiConnectPanel', () => {
|
||||
expect(screen.getByRole('button', { name: /Connect OpenRouter/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-CONNECT-PANEL-008: "Using Claude?" shows with the MCP addon and the sharing details fold out', async () => {
|
||||
it('FE-COMP-AI-CONNECT-PANEL-008: the sharing details fold out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
expect(screen.queryByText('Using Claude? Connect in Claude')).not.toBeInTheDocument();
|
||||
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
await user.click(screen.getByRole('button', { name: /What gets shared\?/ }));
|
||||
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
seedStore(useAddonStore, { addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: true }] });
|
||||
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'Using Claude? Connect in Claude' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-CONNECT-PANEL-009: the admin signing in with ChatGPT is told the plan is shared, and the sign-in is for everyone', async () => {
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/sh
|
||||
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AI_PROVIDERS, API_KEY_KINDS, providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../settingsKit';
|
||||
import ChatGptPasteStep, { ConnectErrorNote } from './ChatGptPasteStep';
|
||||
import { CLAUDE_CONNECTOR_ANCHOR, type AiConnectMode } from './aiConnectModel';
|
||||
import { type AiConnectMode } from './aiConnectModel';
|
||||
import { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterStart } from './useAiConnect';
|
||||
|
||||
interface AiConnectPanelProps {
|
||||
@@ -399,7 +398,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
||||
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
|
||||
const keyConnect = useKeyConnect({ scope, prefill, onConnected });
|
||||
const custom = useCustomConnect({ prefill, onConnected });
|
||||
const mcpEnabled = useAddonStore((s) => s.isEnabled('mcp'));
|
||||
|
||||
// The privacy line names whoever would receive the messages, once that is known.
|
||||
const receivers: Record<AiConnectMode, string> = {
|
||||
@@ -450,18 +448,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
||||
{mode === 'custom' && <CustomForm custom={custom} />}
|
||||
|
||||
<PrivacyNote provider={mode ? receivers[mode] : ''} />
|
||||
{scope === 'user' && mcpEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
document.getElementById(CLAUDE_CONNECTOR_ANCHOR)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
className="self-start font-medium text-content-muted underline underline-offset-2 hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('assistant.connect.claudeLink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// FE-COMP-CLAUDE-CONNECTOR-001 to FE-COMP-CLAUDE-CONNECTOR-003: adding TREK to Claude as a connector.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
|
||||
const repo = vi.hoisted(() => ({ claudeConnector: vi.fn() }));
|
||||
const clipboard = vi.hoisted(() => ({ copyText: vi.fn() }));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
vi.mock('../../../utils/clipboard', () => clipboard);
|
||||
|
||||
const { default: ClaudeConnectorCard } = await import('./ClaudeConnectorCard');
|
||||
|
||||
const CONNECTOR = {
|
||||
mcpUrl: 'https://trip.example.org/mcp',
|
||||
deepLink:
|
||||
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=https%3A%2F%2Ftrip.example.org%2Fmcp',
|
||||
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clipboard.copyText.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('ClaudeConnectorCard', () => {
|
||||
it('FE-COMP-CLAUDE-CONNECTOR-001: links a public install into Claude, with a QR code that is an image, not markup', async () => {
|
||||
repo.claudeConnector.mockResolvedValue(CONNECTOR);
|
||||
const { container } = render(<ClaudeConnectorCard />);
|
||||
const link = await screen.findByRole('link', { name: /Open in Claude/ });
|
||||
expect(link).toHaveAttribute('href', CONNECTOR.deepLink);
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
const qr = screen.getByAltText('QR code to add TREK in Claude');
|
||||
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
||||
// The QR code's own markup never lands in the page; icons may draw rects of their own.
|
||||
expect(container.querySelector('rect[width="1"][height="1"]')).toBeNull();
|
||||
expect(document.getElementById('claude-connector')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-CLAUDE-CONNECTOR-002: copies the address and shows the tick for a moment', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.claudeConnector.mockResolvedValue(CONNECTOR);
|
||||
render(<ClaudeConnectorCard />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy address' }));
|
||||
expect(clipboard.copyText).toHaveBeenCalledWith(CONNECTOR.mcpUrl);
|
||||
await waitFor(() => expect(document.querySelector('.text-success')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('FE-COMP-CLAUDE-CONNECTOR-003: a plain-http or LAN install gets the reason instead of a link, and a failed read only the description', async () => {
|
||||
repo.claudeConnector.mockResolvedValueOnce({ ...CONNECTOR, mcpUrl: 'http://192.168.1.20:3000/mcp' });
|
||||
const { unmount } = render(<ClaudeConnectorCard />);
|
||||
expect(
|
||||
await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
repo.claudeConnector.mockRejectedValueOnce(new Error('404'));
|
||||
render(<ClaudeConnectorCard />);
|
||||
expect(screen.getByText('Use TREK right inside Claude: add it as a connector, sign in, done.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(repo.claudeConnector).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsCard, SettingsHint } from '../settingsKit';
|
||||
import { CLAUDE_CONNECTOR_ANCHOR } from './aiConnectModel';
|
||||
import { useClaudeConnector } from './useClaudeConnector';
|
||||
|
||||
type ClaudeConnector = ReturnType<typeof useClaudeConnector>;
|
||||
|
||||
/** The way in: the button that adds TREK on claude.ai, the address to copy, and the QR code for a phone. */
|
||||
function ConnectorLinks({
|
||||
connector,
|
||||
qrSrc,
|
||||
copied,
|
||||
copyUrl,
|
||||
}: Omit<ClaudeConnector, 'connector' | 'reachable'> & { connector: NonNullable<ClaudeConnector['connector']> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-3">
|
||||
<div className="flex">
|
||||
<a
|
||||
href={connector.deepLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(13, 'body')}
|
||||
>
|
||||
<ExternalLink size={14} strokeWidth={2.2} /> {t('assistant.claude.open')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
dir="ltr"
|
||||
className="block min-w-0 flex-1 truncate rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 font-mono text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{connector.mcpUrl}
|
||||
</code>
|
||||
<Tooltip label={copied ? t('common.copied') : t('assistant.claude.copyUrl')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyUrl()}
|
||||
aria-label={t('assistant.claude.copyUrl')}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={15} strokeWidth={2.2} className="text-success" />
|
||||
) : (
|
||||
<Copy size={15} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<figure className="m-0 flex flex-none flex-col items-center gap-1.5 max-sm:hidden">
|
||||
<img
|
||||
src={qrSrc}
|
||||
alt={t('assistant.claude.qrAlt')}
|
||||
width={112}
|
||||
height={112}
|
||||
className="rounded-[10px] shadow-sm ring-1 ring-edge-faint"
|
||||
/>
|
||||
<figcaption className="text-content-faint" style={fs(11)}>
|
||||
{t('assistant.claude.scan')}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Connect with Claude": TREK as a connector inside Claude, over the MCP
|
||||
* server and its OAuth. One button adds it on claude.ai; the QR code does the
|
||||
* same from a phone, so it only shows where a phone is not already in hand.
|
||||
*/
|
||||
export default function ClaudeConnectorCard(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { connector, reachable, ...links } = useClaudeConnector();
|
||||
|
||||
// Null keeps the card to its band until the address is known.
|
||||
let body: React.ReactNode = null;
|
||||
if (connector && reachable) body = <ConnectorLinks connector={connector} {...links} />;
|
||||
else if (connector) body = <SettingsHint>{t('assistant.claude.notPublic')}</SettingsHint>;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
id={CLAUDE_CONNECTOR_ANCHOR}
|
||||
icon={MessagesSquare}
|
||||
title={t('assistant.claude.title')}
|
||||
hint={t('assistant.claude.description')}
|
||||
>
|
||||
{body}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,6 @@ import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
/** How a connection is being set up: a ChatGPT sign-in, a pasted key, or (admin only) an own server. */
|
||||
export type AiConnectMode = 'chatgpt' | 'key' | 'custom';
|
||||
|
||||
/** Where the Claude connector card sits, for the "Using Claude?" line to jump to. */
|
||||
export const CLAUDE_CONNECTOR_ANCHOR = 'claude-connector';
|
||||
|
||||
/** The `code` of a refused AI request (`{ error, code }`), or null when the failure carries none. */
|
||||
export function refusalCode(err: unknown): string | null {
|
||||
if (err instanceof OnlineOnlyError) return 'offline';
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { AiClaudeConnectorResponse } from '@trek/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
||||
import { copyText } from '../../../utils/clipboard';
|
||||
import { isPublicHttpsUrl, svgDataUrl } from './aiConnectModel';
|
||||
|
||||
/**
|
||||
* Adding TREK to Claude as a connector (no AI connection of TREK's own
|
||||
* involved): the MCP address, the claude.ai link that adds it, and a QR code
|
||||
* for doing it from a phone. Claude reaches TREK from the internet, so on a
|
||||
* LAN or plain-http install the card explains that instead of offering a link
|
||||
* that would fail after the user did everything right.
|
||||
*/
|
||||
export function useClaudeConnector() {
|
||||
const [connector, setConnector] = useState<AiClaudeConnectorResponse | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
aiConnectionsRepo.claudeConnector().then(
|
||||
(next) => {
|
||||
if (!cancelled) setConnector(next);
|
||||
},
|
||||
() => {
|
||||
// Without an answer the card keeps to its description; nothing to link to.
|
||||
if (!cancelled) setConnector(null);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!connector || !(await copyText(connector.mcpUrl))) return;
|
||||
setCopied(true);
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return {
|
||||
connector,
|
||||
reachable: connector ? isPublicHttpsUrl(connector.mcpUrl) : false,
|
||||
qrSrc: connector ? svgDataUrl(connector.qrSvg) : '',
|
||||
copied,
|
||||
copyUrl,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { Place } from '../../types';
|
||||
import { normalizeStyleForProvider } from '../Map/glProviders';
|
||||
import { useWebGL2 } from '../Map/glSupport';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
|
||||
|
||||
@@ -51,6 +52,11 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
}, [settings]);
|
||||
|
||||
const previewPlaces = useMemo((): Place[] => [previewPlace()], []);
|
||||
// Both GL engines need WebGL2. Without it the preview shows the Leaflet map the
|
||||
// trip planner falls back to, and both shells say why.
|
||||
const webgl2 = useWebGL2();
|
||||
const glPreview = provider !== 'leaflet' && webgl2;
|
||||
const webgl2Missing = provider !== 'leaflet' && !webgl2;
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
@@ -105,6 +111,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
mapboxQuality,
|
||||
setMapboxQuality,
|
||||
previewPlaces,
|
||||
glPreview,
|
||||
webgl2Missing,
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('the Templates panel', () => {
|
||||
it('narrows the set to one archetype from the style chips', () => {
|
||||
panel();
|
||||
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(13);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ThemeSheet } from './ThemeSheet';
|
||||
import { useStudioTheme } from './useStudioTheme';
|
||||
|
||||
/**
|
||||
* The Theme section of the rail: twelve built-in looks, the palette, the
|
||||
* The Theme section of the rail: seventeen built-in looks, the palette, the
|
||||
* typefaces and the motifs of the one being put together, and applying it to
|
||||
* the book or to the page that is open.
|
||||
*
|
||||
|
||||
@@ -1,100 +1,56 @@
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookColorToken,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookMapElement,
|
||||
BookMapScope,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
BookTheme,
|
||||
BookTravelMapCard,
|
||||
BookTravelMarkVariant,
|
||||
JourneyStats,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
themedElement,
|
||||
BOOK_TRAVEL_COUNTRY_LOOKS,
|
||||
BOOK_TRAVEL_FIGURE_SHELVES,
|
||||
BOOK_TRAVEL_MARK_STYLES,
|
||||
BOOK_TRAVEL_MARK_VARIANTS,
|
||||
BOOK_TRAVEL_SUMMARY_LOOKS,
|
||||
bookTravelLooks,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared';
|
||||
import { studioTravelContext } from '../studioTravelContext';
|
||||
|
||||
/**
|
||||
* What the Travel panel offers, built: every card is the element its click
|
||||
* places, made by the book engine's own factories (book-travel.ts) so a card
|
||||
* and what the assistant places are one thing, then set in one of the newer
|
||||
* looks and dressed in the book's theme.
|
||||
* places, made by the book's own travel looks (book-travel-looks.ts in
|
||||
* shared), the same factory the auto layout builds its framing pages with,
|
||||
* so a card and a designed page are one thing.
|
||||
*
|
||||
* Pure, so the panel stays a list of shelves: it asks for an element and
|
||||
* renders it through the page's own renderer, and the preview and the result
|
||||
* are the same object.
|
||||
* are the same object. What is the panel's own here is where a new element
|
||||
* lands: centred on the spread on screen.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const SUMMARY_LOOKS: BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
export const SUMMARY_LOOKS: readonly BookStatsLook[] = BOOK_TRAVEL_SUMMARY_LOOKS;
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type MarkVariant = (typeof MARK_VARIANTS)[number];
|
||||
export const MARK_VARIANTS = BOOK_TRAVEL_MARK_VARIANTS;
|
||||
export type MarkVariant = BookTravelMarkVariant;
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
export const MARK_STYLES: readonly { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] =
|
||||
BOOK_TRAVEL_MARK_STYLES;
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
export const FIGURE_SHELVES: readonly { look: BookStatsLook; metrics: readonly BookMetric[] }[] =
|
||||
BOOK_TRAVEL_FIGURE_SHELVES;
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
export const COUNTRY_LOOKS: readonly BookCountryLook[] = BOOK_TRAVEL_COUNTRY_LOOKS;
|
||||
|
||||
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
|
||||
export const MAP_CARDS: {
|
||||
@@ -130,130 +86,19 @@ export function travelBuilders({ page, stats, path, locale, t, theme, single }:
|
||||
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
|
||||
};
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme
|
||||
)
|
||||
: el;
|
||||
const looks = bookTravelLooks({ page, stats, travel, theme, newId: uid, place: at });
|
||||
|
||||
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
|
||||
const summaryEl = (look: BookStatsLook): BookElement => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
return {
|
||||
summaryEl: (look: BookStatsLook) => looks.summaryEl(look),
|
||||
figureEl: (metric: BookMetric, look: BookStatsLook = 'medal') => looks.figureEl(metric, look),
|
||||
mapEl: (
|
||||
card: BookTravelMapCard,
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
) => looks.mapEl(card, scope, source),
|
||||
countryMapEl: (code: string, kind: 'shape' | 'satellite' | 'box') => looks.countryMapEl(code, kind),
|
||||
markEl: (variant: MarkVariant, style: BookBadgeStyle) => looks.markEl(variant, style),
|
||||
countryEl: (code: string, look: BookCountryLook) => looks.countryEl(code, look),
|
||||
countryName: looks.countryName,
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
): BookElement => {
|
||||
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
|
||||
const own = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx = studioTravelContext(own, [], locale, t);
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const el = bookTravelMap(ctx, own, {
|
||||
id: uid('mp'),
|
||||
frame: at(bookTravelSize('map', page)),
|
||||
scope: 'journey',
|
||||
source,
|
||||
});
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
|
||||
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) return null;
|
||||
const frame = at(markSize(style, value.text, value.sub));
|
||||
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
const frame = at({ w: side * ratio, h: side });
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats);
|
||||
|
||||
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
import { buildGlMap } from '../../Map/glSupport';
|
||||
import { isTourLayerHit } from '../../Map/mapRasterOverlay';
|
||||
import {
|
||||
addTerrainLayers,
|
||||
@@ -110,8 +111,11 @@ export function useTerrainMap({ scene, baseLayer, data, exaggeration }: Options)
|
||||
let map: MapLibreMap | null = null;
|
||||
void import('../../Map/engines/maplibre').then(({ default: engine }) => {
|
||||
if (cancelled) return;
|
||||
map = createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle });
|
||||
setGl({ map, engine });
|
||||
// Null when the GPU refused WebGL2. The report switches the planner back to the 2D map.
|
||||
map = buildGlMap(() =>
|
||||
createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle })
|
||||
);
|
||||
if (map) setGl({ map, engine });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -83,6 +83,13 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="text-caption font-semibold mt-3 mb-1 text-m-ink">
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="text-xs font-semibold mt-3 mb-1 text-m-ink">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
|
||||
import { useAssistantHome } from '../../../components/Assistant/useAssistantHome';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
|
||||
|
||||
@@ -15,8 +15,9 @@ import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScree
|
||||
* screen and the question waiting in its composer. Another page a card points
|
||||
* at closes the chat and opens there.
|
||||
*
|
||||
* Inside a trip the trip's own screen (MAssistantScreen) shows its chat, so
|
||||
* this one stays away there. Nothing shows until the assistant has a
|
||||
* On a journal's page it shows that journal's chat instead. Inside a trip
|
||||
* the trip's own screen (MAssistantScreen) shows its chat, so this one stays
|
||||
* away there. Nothing shows until the assistant has a
|
||||
* connection this user can use.
|
||||
*/
|
||||
export default function MAssistantHomeScreen() {
|
||||
@@ -28,9 +29,12 @@ export default function MAssistantHomeScreen() {
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
|
||||
// The trip's screen takes over with the window still open, so its chat comes up there.
|
||||
const switchTo = useCallback((trip: AssistantChatTrip) => void navigate(`/trips/${trip.id}`), [navigate]);
|
||||
// The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there.
|
||||
const switchTo = useCallback((chat: AssistantChatTrip) => void navigate(chatPagePath(chat)), [navigate]);
|
||||
const home = useAssistantHome(switchTo);
|
||||
// On a journal's page the chat is that journal's, the journey's conversation it shares with TREK Studio.
|
||||
const journal = useRouteJournalChat();
|
||||
const chat = journal ?? home.chat;
|
||||
const hops = useMemo(
|
||||
(): MapHops => ({
|
||||
open: (target: EntityTarget) => {
|
||||
@@ -50,11 +54,11 @@ export default function MAssistantHomeScreen() {
|
||||
if (inTrip || !available || !open || minimized) return null;
|
||||
return (
|
||||
<AssistantScreen
|
||||
tripId={home.chat.id}
|
||||
tripTitle={home.chat.title}
|
||||
tripId={chat.id}
|
||||
tripTitle={chat.title}
|
||||
hidden={false}
|
||||
hops={hops}
|
||||
onOpenTripChat={home.openTripChat}
|
||||
onOpenTripChat={journal ? undefined : home.openTripChat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import MPhotoProvidersSection from './MPhotoProvidersSection'
|
||||
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
|
||||
import MDawarichConnectionSection from './MDawarichConnectionSection'
|
||||
import MLlmConnectionSection from './MLlmConnectionSection'
|
||||
import MAiConnectionSection from './MAiConnectionSection'
|
||||
import MSettingsMcp from './MSettingsMcp'
|
||||
import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'
|
||||
import { useIntegrationGates } from '../../../components/Settings/useIntegrationGates'
|
||||
import { useClaudeConnector } from '../../../components/Settings/ai/useClaudeConnector'
|
||||
import { CLAUDE_CONNECTOR_ANCHOR } from '../../../components/Settings/ai/aiConnectModel'
|
||||
|
||||
/**
|
||||
* "Integrations" section. The photo-provider / AirTrail / LLM connection forms
|
||||
@@ -33,35 +28,7 @@ export default function MSettingsIntegrations() {
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
|
||||
{mcpEnabled && <MClaudeConnectorCard />}
|
||||
{mcpEnabled && <MSettingsMcp />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Connect with Claude" on the phone: the button and the address only. The QR
|
||||
* code is for reaching a phone from a desktop, and this is the phone.
|
||||
*/
|
||||
function MClaudeConnectorCard() {
|
||||
const { t } = useTranslation()
|
||||
const { connector, reachable, copied, copyUrl } = useClaudeConnector()
|
||||
return (
|
||||
<div id={CLAUDE_CONNECTOR_ANCHOR}>
|
||||
<MSetCard title={t('assistant.claude.title')} icon={MessagesSquare} className="mt-3">
|
||||
<MSetHint className="mb-3">{t('assistant.claude.description')}</MSetHint>
|
||||
{connector && !reachable && <MSetHint>{t('assistant.claude.notPublic')}</MSetHint>}
|
||||
{connector && reachable && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<MSetButton onClick={() => window.open(connector.deepLink, '_blank', 'noopener,noreferrer')}>
|
||||
<ExternalLink size={14} /> {t('assistant.claude.open')}
|
||||
</MSetButton>
|
||||
<MSetButton variant="ghost" onClick={() => void copyUrl()}>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? t('common.copied') : t('assistant.claude.copyUrl')}
|
||||
</MSetButton>
|
||||
</div>
|
||||
)}
|
||||
</MSetCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function MSettingsMap() {
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, save,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
|
||||
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
|
||||
const [presetOpen, setPresetOpen] = useState(false)
|
||||
const [styleOpen, setStyleOpen] = useState(false)
|
||||
@@ -170,7 +170,7 @@ export default function MSettingsMap() {
|
||||
)}
|
||||
|
||||
<div className="relative mt-3 h-[200px] w-full overflow-hidden rounded-xl">
|
||||
{provider !== 'leaflet' ? (
|
||||
{glPreview ? (
|
||||
/* See MapSettingsTab: the preview gets its own net. */
|
||||
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-m-card" />}>
|
||||
<Suspense fallback={<div className="h-full w-full bg-m-card animate-pulse" />}>
|
||||
@@ -208,6 +208,7 @@ export default function MSettingsMap() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{webgl2Missing && <MSetHint>{t('map.webgl2.fallback')}</MSetHint>}
|
||||
|
||||
<MSetButton className="mt-3" onClick={save} disabled={saving}>
|
||||
<Save size={14} />
|
||||
|
||||
@@ -317,6 +317,8 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
|
||||
onFocusDay={hops.focusDay}
|
||||
onRevealMap={hops.revealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
// The phone's Studio has its own panel: a journey's chat here is the journal's.
|
||||
journal={assistantScopeOf(tripId).kind === 'journey'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
{hasReply && (
|
||||
<div className="mb-1 rounded-[10px] bg-[color:var(--m-card)] px-[10px] py-[5px]">
|
||||
<div className="font-geist text-[0.625rem] font-bold opacity-70">{msg.reply_username}</div>
|
||||
<div className="truncate text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
<div className="line-clamp-2 text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
|
||||
|
||||
@@ -6,7 +6,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import EmptyState from '../components/shared/EmptyState'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { Spinner } from '../components/shared/Spinner'
|
||||
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
|
||||
import NotificationDayList from '../components/Notifications/NotificationDayList'
|
||||
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
|
||||
|
||||
export default function InAppNotificationsPage(): React.ReactElement {
|
||||
@@ -88,27 +88,25 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
{/* The list, one card per day. */}
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-edge border-t-current" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card">
|
||||
<EmptyState scene="notifications" title={t('notifications.empty')} />
|
||||
) : (
|
||||
displayed.map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NotificationDayList notifications={displayed} />
|
||||
)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
||||
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
||||
{isLoading && <Spinner className="w-5 h-5 border-2 border-edge border-t-current" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={confirmingDeleteAll}
|
||||
|
||||
@@ -37,10 +37,13 @@ vi.mock('../../api/websocket', async importOriginal => ({
|
||||
|
||||
// The auto layout lives in @trek/shared now; the hook's call into it is watched through a partial mock.
|
||||
const shared = await vi.importActual<typeof import('@trek/shared')>('@trek/shared')
|
||||
const buildBook = vi.fn(shared.buildBook)
|
||||
const autoDesignedBook = vi.fn(shared.autoDesignedBook)
|
||||
vi.mock('@trek/shared', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@trek/shared')>()
|
||||
return { ...actual, buildBook: (...args: Parameters<typeof actual.buildBook>) => buildBook(...args) }
|
||||
return {
|
||||
...actual,
|
||||
autoDesignedBook: (...args: Parameters<typeof actual.autoDesignedBook>) => autoDesignedBook(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { useJourneyStudio } from './useJourneyStudio'
|
||||
@@ -67,7 +70,7 @@ function seedJourney() {
|
||||
|
||||
beforeEach(() => {
|
||||
seedJourney()
|
||||
buildBook.mockClear()
|
||||
autoDesignedBook.mockClear()
|
||||
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
|
||||
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
|
||||
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
|
||||
@@ -140,8 +143,8 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
expect(buildBook).toHaveBeenCalled()
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
expect(autoDesignedBook).toHaveBeenCalled()
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([TRACK])
|
||||
})
|
||||
|
||||
@@ -176,9 +179,51 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([])
|
||||
})
|
||||
|
||||
it("lays the whole book on the designed pages in the book's own page format, theme and title", async () => {
|
||||
const { result } = await mountStudio()
|
||||
const theme = shared.bookThemeOf('midnight')
|
||||
const page = { ...shared.bookPageSetupSchema.parse({}), pageWidth: 200 }
|
||||
act(() => {
|
||||
useStudioStore.getState().commit(d => ({ ...d, title: 'My own title', page, theme }))
|
||||
})
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const [doc, input, opts] = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1]
|
||||
expect(input.page).toEqual(page)
|
||||
expect(opts?.theme).toEqual(theme)
|
||||
expect(doc?.title).toBe('My own title')
|
||||
const laid = useStudioStore.getState().doc
|
||||
expect(laid?.title).toBe('My own title')
|
||||
expect(laid?.theme).toEqual(theme)
|
||||
expect(laid?.page.pageWidth).toBe(200)
|
||||
expect(laid?.spreads.some(s => s.role === 'inner' && s.entryId === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('lays this spread on another design, its id kept, and knows the cover but not the back', async () => {
|
||||
const { result } = await mountStudio()
|
||||
act(() => { result.current.relayoutBook() })
|
||||
const before = useStudioStore.getState().doc
|
||||
if (!before) throw new Error('a laid book')
|
||||
const at = before.spreads.findIndex(s => s.role === 'inner' && s.entryId === 1)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(at) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
const was = shared.matchDecoratedTemplate(before.spreads[at], before.page)?.id
|
||||
|
||||
act(() => { result.current.relayoutCurrentSpread() })
|
||||
|
||||
const after = useStudioStore.getState().doc?.spreads[at]
|
||||
expect(after?.id).toBe(before.spreads[at].id)
|
||||
expect(shared.matchDecoratedTemplate(after as never, before.page)?.id).not.toBe(was)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(0) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(before.spreads.length - 1) })
|
||||
expect(result.current.canRelayoutSpread).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router'
|
||||
import type { AutoEntry, AutoInput, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import type { AutoDesignedInput, AutoEntry, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
autoEntriesWithStops, bookElementId as elementId, bookPageSetupSchema, bookPath, buildBook, distributeGallery,
|
||||
emptyBook, layoutAroundLocks, relayoutSpread,
|
||||
autoDesignedBook, autoEntriesWithStops, autoRelayoutKind, autoRelayoutSpread, bookElementId as elementId,
|
||||
bookPageSetupSchema, bookPath, emptyBook,
|
||||
} from '@trek/shared'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
|
||||
@@ -153,7 +153,7 @@ export function useJourneyStudio() {
|
||||
* possible; rebuilding it from the journey on every click would be the same
|
||||
* work plus a chance of the two disagreeing.
|
||||
*/
|
||||
const autoInput = useRef<AutoInput | null>(null)
|
||||
const autoInput = useRef<AutoDesignedInput | null>(null)
|
||||
|
||||
const journey = current && current.id === journeyId ? current : null
|
||||
|
||||
@@ -274,15 +274,15 @@ export function useJourneyStudio() {
|
||||
})),
|
||||
}))
|
||||
|
||||
// When each loose photo was taken: the auto layout gives it to the entry of its day first.
|
||||
const gallery: AutoPhoto[] = (journey.gallery || []).map((p: GalleryPhoto) => ({
|
||||
photoId: p.photo_id,
|
||||
width: p.width ?? null,
|
||||
height: p.height ?? null,
|
||||
caption: p.caption ?? null,
|
||||
takenAt: p.taken_at ?? null,
|
||||
}))
|
||||
|
||||
const withPhotos = distributeGallery(entries, gallery)
|
||||
|
||||
/*
|
||||
* Give every entry what the journey's figures know about its stop: the
|
||||
* coordinates and country its place resolved to and which day it is, so a
|
||||
@@ -290,7 +290,7 @@ export function useJourneyStudio() {
|
||||
* is shared (autoEntriesWithStops) so the server's design tool reads the
|
||||
* same stops.
|
||||
*/
|
||||
const placed = autoEntriesWithStops(withPhotos, stats)
|
||||
const placed = autoEntriesWithStops(entries, stats)
|
||||
|
||||
const preset = PAGE_PRESETS['square-210']
|
||||
|
||||
@@ -300,6 +300,8 @@ export function useJourneyStudio() {
|
||||
subtitle: journey.subtitle ?? null,
|
||||
coverPhotoId: gallery[0]?.photoId ?? null,
|
||||
entries: placed,
|
||||
// The loose photos stay loose here: the layout shares them out itself, by day, and loses none.
|
||||
gallery,
|
||||
page: {
|
||||
preset: preset.id,
|
||||
pageWidth: preset.pageWidthMm,
|
||||
@@ -375,7 +377,7 @@ export function useJourneyStudio() {
|
||||
*
|
||||
* The build effect runs as soon as the book and the figures are in, which is
|
||||
* usually before this answers, and what it froze is what the Auto layout
|
||||
* buttons hand to buildBook afterwards. Without the patch a relayout drew
|
||||
* buttons hand to the layout afterwards. Without the patch a relayout drew
|
||||
* the straight line between stops over a route somebody had walked.
|
||||
*/
|
||||
const applyPath = (p: [number, number][][]) => {
|
||||
@@ -471,8 +473,10 @@ export function useJourneyStudio() {
|
||||
const relayoutBook = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
// A new seed every press: "the whole book" twice gives two books, each one undo away.
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => ({
|
||||
...buildBook({ ...input, page: d.page }),
|
||||
...autoDesignedBook(d, { ...input, page: d.page }, { theme: d.theme, seed }),
|
||||
title: d.title,
|
||||
}))
|
||||
setActiveSpread(0)
|
||||
@@ -481,22 +485,20 @@ export function useJourneyStudio() {
|
||||
const relayoutCurrentSpread = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => {
|
||||
const sp = d.spreads[activeSpread]
|
||||
if (!sp) return d
|
||||
// Laid out around what is locked, as the assistant's auto layout leaves it.
|
||||
const next = layoutAroundLocks(sp, free => relayoutSpread(free, { ...input, page: d.page }))
|
||||
// Laid out around what is locked, its id kept, on another template than the one on screen.
|
||||
const next = autoRelayoutSpread(d, activeSpread, { ...input, page: d.page }, { seed })
|
||||
if (!next) return d
|
||||
return { ...d, spreads: d.spreads.map((x, i) => (i === activeSpread ? next : x)) }
|
||||
})
|
||||
select([])
|
||||
}, [commit, activeSpread, select])
|
||||
|
||||
/** Whether the spread on screen came from an entry, and so can be redone. */
|
||||
/** Whether the auto layout knows the spread on screen (an entry's page, the cover, the summary, the countries). */
|
||||
const canRelayoutSpread = !!(
|
||||
autoInput.current
|
||||
&& doc?.spreads[activeSpread]?.entryId != null
|
||||
&& doc.spreads[activeSpread].role === 'inner'
|
||||
&& autoRelayoutKind(doc?.spreads[activeSpread]) !== null
|
||||
)
|
||||
|
||||
/** Page numbers and anything else that belongs to the book rather than a page. */
|
||||
|
||||
@@ -5,11 +5,13 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/
|
||||
import { idleLegVariants, idleRoundHikes } from '../../../tests/helpers/tourPlannerStub';
|
||||
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
|
||||
import type { MapViewProps } from '../../components/Map/mapViewContract';
|
||||
import { ToastContainer } from '../../components/shared/Toast';
|
||||
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
|
||||
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTourMapViewportStore } from '../../store/tourMapViewportStore';
|
||||
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
|
||||
import { resetWebGLProbe } from '../../utils/webgl';
|
||||
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
|
||||
|
||||
type Handler = (event?: unknown) => void;
|
||||
@@ -75,6 +77,12 @@ const stage = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
vi.mock('../../components/Map/engines/maplibre', () => ({ default: stage.engine }));
|
||||
// jsdom has no WebGL2: the stage is told it is there, except where a case takes it away.
|
||||
const webgl = vi.hoisted(() => ({ available: true }));
|
||||
vi.mock('../../utils/webgl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/webgl')>()),
|
||||
hasWebGL: () => webgl.available,
|
||||
}));
|
||||
vi.mock('../../components/Map/MapViewAuto', () => ({
|
||||
MapViewAuto: (props: MapViewProps) => {
|
||||
stage.flatMap.props = props;
|
||||
@@ -185,6 +193,8 @@ async function openTerrain() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
webgl.available = true;
|
||||
resetWebGLProbe();
|
||||
window.sessionStorage.clear();
|
||||
useTourPlaceToolStore.setState(initialTourPlaceToolState());
|
||||
stage.listeners.clear();
|
||||
@@ -928,3 +938,44 @@ describe('TourPlannerMapStage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('3D without WebGL2', () => {
|
||||
// The stage translates with the t it is handed, and stageProps hands it one that returns the key.
|
||||
const NO_3D = 'map.webgl2.terrain';
|
||||
|
||||
function renderWithToasts(tourPlanner: Planner) {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<TourPlannerMapStage {...stageProps(tourPlanner, true)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-WEBGL-001: the 3D switch keeps the 2D map and says why', async () => {
|
||||
webgl.available = false;
|
||||
renderWithToasts(planner('edit-saved'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
|
||||
|
||||
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'flat map' })).toBeInTheDocument();
|
||||
expect(stage.engine.Map).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-WEBGL-002: a GPU that refuses the terrain context brings the 2D map back', async () => {
|
||||
stage.engine.Map.mockImplementationOnce(function () {
|
||||
webgl.available = false;
|
||||
const err = new Error('WebGL2 is required to display this map.');
|
||||
err.name = 'GPUInitializationError';
|
||||
throw err;
|
||||
});
|
||||
renderWithToasts(planner('edit-saved'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
|
||||
|
||||
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'flat map' })).toBeInTheDocument();
|
||||
expect(stage.engine.Map).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
|
||||
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
|
||||
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
|
||||
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
|
||||
import { useWebGL2 } from '../../components/Map/glSupport';
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
|
||||
import { MapUndoHint } from '../../components/Map/MapUndoHint';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { useToast } from '../../components/shared/Toast';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
|
||||
@@ -25,6 +27,7 @@ import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddle
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
|
||||
import { publishTourMapViewport } from '../../store/tourMapViewportStore';
|
||||
import { subscribeWebGL } from '../../utils/webgl';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
import { useOneCornerCard } from './useOneCornerCard';
|
||||
import { useTourMapPoints } from './useTourMapPoints';
|
||||
@@ -112,7 +115,22 @@ export function TourPlannerMapStage({
|
||||
paddleChecklist,
|
||||
assistantRoutes,
|
||||
}: Readonly<TourPlannerMapStageProps>): ReactElement {
|
||||
const [terrain3d, setTerrain3d] = useState(false);
|
||||
const [terrainAsked, setTerrain3d] = useState(false);
|
||||
// The terrain is a MapLibre map, so it needs WebGL2. Without it, or once its GPU
|
||||
// refused the context, the stage stays on the 2D map and says why.
|
||||
const webgl2 = useWebGL2();
|
||||
const terrain3d = terrainAsked && webgl2;
|
||||
const toast = useToast();
|
||||
const openTerrain = useCallback(() => {
|
||||
if (webgl2) setTerrain3d(true);
|
||||
else toast.info(t('map.webgl2.terrain'));
|
||||
}, [webgl2, toast, t]);
|
||||
// A GPU that offered WebGL2 and then refused the terrain's context: the 2D map is
|
||||
// back by itself, and this says why.
|
||||
useEffect(() => {
|
||||
if (!terrainAsked) return;
|
||||
return subscribeWebGL(() => toast.info(t('map.webgl2.terrain')));
|
||||
}, [terrainAsked, toast, t]);
|
||||
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
|
||||
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
|
||||
const [terrainLegend, setTerrainLegend] = useState<TerrainLegendLayout | null>(null);
|
||||
@@ -287,7 +305,7 @@ export function TourPlannerMapStage({
|
||||
viewBaseLayer={baseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
tourLayers={tourLayers}
|
||||
onToggleTerrain3d={() => setTerrain3d(true)}
|
||||
onToggleTerrain3d={openTerrain}
|
||||
ferrataLayer={ferrataLayer}
|
||||
onViewportChange={viewportChanged}
|
||||
placeTool={placeTool}
|
||||
|
||||
@@ -126,6 +126,5 @@ describe('aiConnectionsRepo', () => {
|
||||
await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.claudeConnector()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
aiAdminConnectionResponseSchema,
|
||||
aiAdminSettingsSchema,
|
||||
aiAuthStartResponseSchema,
|
||||
aiClaudeConnectorResponseSchema,
|
||||
aiConnectionViewSchema,
|
||||
aiModelListResponseSchema,
|
||||
aiStatusResponseSchema,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
type AiOpenrouterFinishRequest,
|
||||
type AiPrivacyAckRequest,
|
||||
} from '@trek/shared';
|
||||
import { aiAdminApi, aiApi, claudeConnectorApi } from '../api/assistant';
|
||||
import { aiAdminApi, aiApi } from '../api/assistant';
|
||||
import { requireOnline } from './onlineOnly';
|
||||
|
||||
/**
|
||||
@@ -114,11 +113,4 @@ export const aiConnectionsRepo = {
|
||||
requireOnline();
|
||||
return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
|
||||
},
|
||||
|
||||
// ── Claude connector ───────────────────────────────────────────────────────
|
||||
|
||||
async claudeConnector() {
|
||||
requireOnline();
|
||||
return aiClaudeConnectorResponseSchema.parse(await claudeConnectorApi.get());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -96,6 +96,20 @@ describe('assistantStore: a chat hosted by the Studio', () => {
|
||||
expect(send.mock.calls[1]?.[1]).not.toHaveProperty('renders');
|
||||
});
|
||||
|
||||
it("FE-AI-STORE-STUDIO-008: a journey's turn names its page, the Studio's while hosted, else the journal's", async () => {
|
||||
const release = store().hostStudio(KEY, fakeHost([]));
|
||||
const send = vi.spyOn(assistantRepo, 'send').mockImplementation(async () => streamOf([{ type: 'finish' }]));
|
||||
|
||||
await store().send(KEY, 'Lay out the cover');
|
||||
release();
|
||||
await store().send(KEY, 'A new entry for Berlin Hauptbahnhof');
|
||||
await store().send(5, 'A trip question');
|
||||
|
||||
expect(send.mock.calls[0]?.[1]).toMatchObject({ surface: 'studio' });
|
||||
expect(send.mock.calls[1]?.[1]).toMatchObject({ surface: 'journal' });
|
||||
expect(send.mock.calls[2]?.[1]).not.toHaveProperty('surface');
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-STUDIO-003: hands the page a turn asks for to the host, with a signal that ends with the turn', async () => {
|
||||
const host = fakeHost([]);
|
||||
store().hostStudio(KEY, host);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AssistantErrorCode,
|
||||
type AssistantFocus,
|
||||
type AssistantFoundPlace,
|
||||
type AssistantJourneySurface,
|
||||
type AssistantMapView,
|
||||
type AssistantMention,
|
||||
type AssistantMessage,
|
||||
@@ -270,16 +271,21 @@ async function settleHolds(ctx: Ctx, key: AssistantChatKey): Promise<void> {
|
||||
/** The Studio hosting the chat under `key`, if one does. */
|
||||
const studioHostOf = (ctx: Ctx, key: AssistantChatKey): AssistantStudioHost | undefined => ctx.get().studioHosts[key];
|
||||
|
||||
/** A turn of a hosted chat writes the book first and says the Studio draws pages; any other turn opens as it is. */
|
||||
async function hostedOpen<T>(ctx: Ctx, key: AssistantChatKey, open: (renders: { renders?: true }) => Promise<T>) {
|
||||
type HostedFields = { renders?: true; surface?: AssistantJourneySurface };
|
||||
|
||||
/**
|
||||
* A turn of a hosted chat writes the book first and says the Studio draws pages; any other turn opens as it is.
|
||||
* A journey's turn names its page: the Studio's while the Studio hosts the chat, else the journal's.
|
||||
*/
|
||||
async function hostedOpen<T>(ctx: Ctx, key: AssistantChatKey, open: (fields: HostedFields) => Promise<T>) {
|
||||
const host = studioHostOf(ctx, key);
|
||||
if (!host) return open({});
|
||||
if (!host) return open(assistantScopeOf(key).kind === 'journey' ? { surface: 'journal' } : {});
|
||||
try {
|
||||
await host.flush();
|
||||
} catch {
|
||||
// The Studio's save indicator shows its own failure; the turn goes on against the book the server holds.
|
||||
}
|
||||
return open({ renders: true });
|
||||
return open({ renders: true, surface: 'studio' });
|
||||
}
|
||||
|
||||
const NO_LIVE: AssistantTripChat['live'] = { activity: null };
|
||||
|
||||
@@ -22,11 +22,11 @@ describe('parseReleaseNotes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELNOTES-003: lines are trimmed and markers need their space', () => {
|
||||
it('FE-UTIL-RELNOTES-003: lines are trimmed, markers need their space and #### heads a feature', () => {
|
||||
expect(parseReleaseNotes(' ## Spaced \n-no space\n#### deep\n\n\n')).toEqual([
|
||||
{ kind: 'h3', text: 'Spaced' },
|
||||
{ kind: 'p', text: '-no space' },
|
||||
{ kind: 'p', text: '#### deep' },
|
||||
{ kind: 'h5', text: 'deep' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** One block of a release body: a heading, a paragraph, or a run of list items. */
|
||||
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'p'; text: string } | { kind: 'ul'; items: string[] };
|
||||
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'h5' | 'p'; text: string } | { kind: 'ul'; items: string[] };
|
||||
|
||||
/** Escapes the characters that would let release text break out of the HTML it is placed in. */
|
||||
export function escapeReleaseHtml(str: string): string {
|
||||
@@ -7,8 +7,8 @@ export function escapeReleaseHtml(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a GitHub release body into the blocks both admin shells render: `### ` and `## `
|
||||
* headings, `- ` or `* ` list items gathered into one list until anything else breaks the
|
||||
* Splits a GitHub release body into the blocks both admin shells render: `## `, `### ` and
|
||||
* `#### ` headings (the version, a section such as Added, a feature inside it), `- ` or `* ` list items gathered into one list until anything else breaks the
|
||||
* run, and every other non-empty line as a paragraph. Blank lines only end a list. The
|
||||
* text stays raw; each shell formats it inline itself.
|
||||
*/
|
||||
@@ -30,7 +30,10 @@ export function parseReleaseNotes(body: string): ReleaseNoteBlock[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
if (trimmed.startsWith('#### ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h5', text: trimmed.slice(5) });
|
||||
} else if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h4', text: trimmed.slice(4) });
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
|
||||
@@ -42,15 +42,15 @@ describe('hasWebGL', () => {
|
||||
expect(hasWebGL()).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-WEBGL-003: falls back to webgl1 when only webgl2 is refused', () => {
|
||||
// Exactly the order maplibre-gl asks in. A stricter probe would black out
|
||||
// maps that would in fact have drawn.
|
||||
it('FE-UTIL-WEBGL-003: says no when only webgl1 is on offer', () => {
|
||||
// MapLibre 6 asks for webgl2 and nothing else. A probe that settled for
|
||||
// webgl1 would wave the map through to a GPUInitializationError.
|
||||
const { ctx } = fakeContext()
|
||||
const getContext = vi.fn((type: string) => (type === 'webgl2' ? null : ctx))
|
||||
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext
|
||||
|
||||
expect(hasWebGL()).toBe(true)
|
||||
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2', 'webgl'])
|
||||
expect(hasWebGL()).toBe(false)
|
||||
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2'])
|
||||
})
|
||||
|
||||
it('FE-UTIL-WEBGL-004: gives the probe context straight back', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Whether this browser will hand MapLibre a WebGL context.
|
||||
* Whether this browser will hand a GL map a WebGL2 context. MapLibre 6 and
|
||||
* Mapbox GL 3 both ask for webgl2 and nothing else.
|
||||
*
|
||||
* The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
|
||||
* its GL context inside the Map constructor: no context, no map, and the throw
|
||||
@@ -28,7 +29,39 @@ export function hasWebGL(): boolean {
|
||||
return probed
|
||||
}
|
||||
|
||||
/** Test seam. Nothing in the app clears this: the answer cannot change mid-session. */
|
||||
const listeners = new Set<() => void>()
|
||||
let failures = 0
|
||||
|
||||
/**
|
||||
* A GL map that failed to build although the probe said yes: a GPU that offers
|
||||
* WebGL2 and then cannot create the context, or a context budget that ran out.
|
||||
* From here on the answer is no, and every map that asked is told, so it can
|
||||
* redraw itself without WebGL instead of waiting for the next page load.
|
||||
*/
|
||||
export function reportWebGLFailure(): void {
|
||||
if (probed === false) return
|
||||
probed = false
|
||||
failures++
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/** For useSyncExternalStore: tells a map when reportWebGLFailure changed the answer. */
|
||||
export function subscribeWebGL(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** For useSyncExternalStore: changes each time reportWebGLFailure turned the answer to no. */
|
||||
export function webGLFailures(): number {
|
||||
return failures
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam. The app never clears this, it only turns it to no through
|
||||
* reportWebGLFailure: a browser does not gain WebGL2 mid-session.
|
||||
*/
|
||||
export function resetWebGLProbe(): void {
|
||||
probed = null
|
||||
}
|
||||
@@ -45,12 +78,13 @@ function probe(): boolean {
|
||||
canvas.width = 1
|
||||
canvas.height = 1
|
||||
|
||||
// Exactly the order maplibre-gl asks in (Map._setupPainter): webgl2 first,
|
||||
// webgl1 second. A stricter probe would black out maps that would in fact
|
||||
// have drawn; a looser one would let the throw through anyway.
|
||||
const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as WebGLRenderingContext | null
|
||||
// Exactly what maplibre-gl asks for (Map._setupPainter): WebGL2 and nothing
|
||||
// else, since MapLibre 6 dropped WebGL1. A browser that only offers WebGL1
|
||||
// passes a looser probe and then throws GPUInitializationError out of the Map
|
||||
// constructor, which is the crash this probe exists to prevent.
|
||||
const gl = canvas.getContext('webgl2')
|
||||
if (!gl) {
|
||||
console.warn('[basemap] this browser refuses a WebGL context, vector basemaps are unavailable')
|
||||
console.warn('[basemap] this browser refuses a WebGL2 context, vector basemaps are unavailable')
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user