Compare commits

..

1 Commits

Author SHA1 Message Date
Maurice 19ae607f54 feat(assistant): the assistant opens outside a trip, as a home chat about all trips
A third scope, home, next to trip and journey: threads that name neither a
trip nor a journey (the CHECK now only forbids both), reached under
/api/assistant by their owner only. A home turn reads the user's trips
through list_trips into a short card (under way, next, later, behind,
undated), offers every read (a trip's on the id the model names, checked by
the tool with the user's rights) and only the writes that take no trip or
journey (atlas, vacation planner, collections, tour library, settings), all
held as change cards. open_trip_chat answers with a card that goes on in
that trip's own chat with the question waiting in its composer.

On the desktop the window opens on the home chat outside a trip; its head
lists the trips to switch into and a switched trip chat has the way back.
On the phone the home chat opens full screen from the dashboard head and the
dock's More list, and the card opens the trip with its chat. Both run on
useAssistantHome.
2026-10-23 21:19:25 +02:00
2144 changed files with 17028 additions and 154976 deletions
+1
View File
@@ -22,6 +22,7 @@ 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/
-51
View File
@@ -53,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
+68 -98
View File
@@ -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, 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.
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
<table>
<tr>
@@ -70,110 +70,76 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🧭 Planning
- **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
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
</td>
<td width="50%" valign="top">
#### 🥾 Tours
- **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
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 👥 Collaboration
- **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
- **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
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 📔 Journal, Atlas and Vacay
#### 📔 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
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
- **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
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
</td>
</tr>
@@ -182,26 +148,30 @@ 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, 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
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
- **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
- **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
- **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
- **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
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
</td>
</tr>
-3
View File
@@ -112,9 +112,6 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
-1
View File
@@ -108,7 +108,6 @@ env:
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# GOOGLE_LIST_DATA_BASE: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
-67
View File
@@ -1,67 +0,0 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+5 -165
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -460,20 +460,6 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
const thumbs = (page: Page) => page.locator('.st-thumb-row')
/** The hit targets over the spread, one per element. */
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
/**
* The hit target of the first text on the spread on screen. The targets above the page are empty
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
*/
async function markTextTarget(page: Page): Promise<void> {
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
await expect(words).toBeVisible()
await words.evaluate(el => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
hit?.setAttribute('data-help-text', '')
})
}
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
async function studioSection(page: Page, name: string): Promise<void> {
await railButton(page, name).click()
@@ -1314,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-head-close'),
target: p => p.locator('.st-back'),
},
],
},
@@ -1445,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
prepare: async p => {
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p)
},
@@ -1472,162 +1458,16 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'studio-my-layouts': {
guide: guide('studio-my-layouts'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layouts'),
act: async p => { await studioSection(p, 'Layouts') },
},
{
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-saved-layouts'),
},
],
},
'studio-theme': {
guide: guide('studio-theme'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Theme'),
act: async p => { await studioSection(p, 'Theme') },
},
{
target: p => sidePanel(p).locator('.st-theme-grid'),
act: async p => {
await sidePanel(p).locator('.st-theme-card').nth(1).click()
await settle(p)
},
},
{
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-theme-apply'),
},
],
},
'studio-stickers': {
guide: guide('studio-stickers'),
start: openStudio,
steps: [
{
prepare: async p => { await studioSection(p, 'Elements') },
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
act: async p => {
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-shape-grid').first(),
},
],
},
'studio-travel': {
guide: guide('studio-travel'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Travel'),
act: async p => {
await studioSection(p, 'Travel')
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
act: async p => { await studioSection(p, 'Travel') },
},
{
target: p => sidePanel(p).locator('.st-travel-grid').first(),
},
{
target: p => sidePanel(p).locator('.st-travel-card').first(),
},
],
},
'studio-text-effects': {
guide: guide('studio-text-effects'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
await markTextTarget(p)
},
target: p => textTarget(p),
act: async p => {
await textTarget(p).click()
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const head = inspector(p).getByRole('button', { name: 'Effects' })
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
await head.scrollIntoViewIfNeeded()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
},
],
},
'studio-background': {
guide: guide('studio-background'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
},
{
target: p => inspector(p).locator('.st-paint-tabs').first(),
},
],
},
'studio-layers': {
guide: guide('studio-layers'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layers'),
act: async p => {
await studioSection(p, 'Layers')
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-layer').first(),
target: sidePanel,
},
],
},
+6 -31
View File
@@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -41,45 +41,20 @@
"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\""
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -92,7 +67,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "6.15.0",
"maplibre-gl": "5.24.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
@@ -106,7 +81,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25",
"zod": "^4.6.7",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+13 -13
View File
@@ -1,26 +1,26 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 854,
"no-console": 88,
"complexity": 221,
"complexity": 226,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
"trek/no-swallowed-catch": 131,
"max-params": 33,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"max-lines-per-function": 132,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 45,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1929,
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+1
View File
@@ -601,6 +601,7 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [
"src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts"
],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
+3
View File
@@ -55,6 +55,7 @@
"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",
@@ -1122,6 +1123,8 @@
"src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.tsx",
"src/components/shared/PlaceAvatar.tsx",
"src/components/shared/PlaceAvatarUpload.test.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/components/shared/PluginIcon.test.tsx",
"src/components/shared/PluginIcon.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx",
+5 -14
View File
@@ -117,6 +117,11 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
},
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}',
@@ -139,20 +144,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Studio/stickerLibrary.ts',
template: 'journey.studio.sticker.${id}',
because:
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
'sticker the library holds against en',
},
{
file: 'components/Studio/shapes.ts',
template: 'journey.studio.shapeName.${shape}',
because:
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
'shape of BOOK_SHAPE_GROUPS',
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -26,7 +26,7 @@
"packing.ts": 6,
"pdf.ts": 1,
"perm.ts": 4,
"places.ts": 9,
"places.ts": 10,
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 36,
+2 -2
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Studio/travel/BadgeIcon.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1,
+1
View File
@@ -116,6 +116,7 @@
"src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts",
+34
View File
@@ -0,0 +1,34 @@
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 -3
View File
@@ -18,6 +18,7 @@
"components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1,
@@ -51,12 +52,13 @@
"components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4,
"components/Studio/SpreadView.tsx": 4,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/SpreadView.tsx": 8,
"components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
@@ -71,6 +73,7 @@
"components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3,
+2 -5
View File
@@ -1,7 +1,4 @@
{
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
"pages/tripPlanner/PlanMapStage.tsx": 4,
"mobile/components/trek3d/MascotClip.tsx": 1
}
+8 -8
View File
@@ -6,25 +6,25 @@
"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": 2919,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/TripPlannerPage.tsx": 1131,
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1918
"src/styles/studio.css": 2087
}
+4 -1
View File
@@ -51,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1,
"src/components/Planner/PlaceInspector.tsx": 16,
"src/components/Planner/PlaceInspector.tsx": 17,
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -82,6 +82,8 @@
"src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
@@ -96,6 +98,7 @@
"src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
+8 -5
View File
@@ -52,7 +52,7 @@
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/InAppNotificationBell.tsx": 8,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
@@ -72,6 +72,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 8,
"components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -99,13 +100,13 @@
"components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5,
"components/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -131,6 +132,7 @@
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2,
@@ -250,7 +252,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
@@ -294,6 +296,7 @@
"pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 9,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
+2 -4
View File
@@ -40,14 +40,12 @@
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 2,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 3,
"components/Studio/TravelElements.tsx": 6,
"components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4,
"components/Studio/travel/badgeLook.ts": 2,
"components/Trips/TripMemberAvatar.tsx": 2,
"components/Vacay/VacayPersons.tsx": 1,
"components/shared/DialogShell.tsx": 2,
+5 -8
View File
@@ -5,6 +5,7 @@ import {
type AiApiKeyConnectRequest,
type AiAuthStartResponse,
type AiChatgptFinishRequest,
type AiClaudeConnectorResponse,
type AiConnectionScope,
type AiConnectionView,
type AiInstanceConnectionRequest,
@@ -17,7 +18,6 @@ import {
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse,
type AssistantScope,
type AssistantShareRequest,
@@ -145,13 +145,6 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
insertFoundPlace: (
tripId: number,
threadId: number,
body: AssistantInsertFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: (
tripId: number,
@@ -232,3 +225,7 @@ 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),
};
@@ -712,33 +712,4 @@ describe('AddonManager', () => {
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
])
);
render(<AddonManager />);
await screen.findByText('Todo List');
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
expect(screen.getByText('Budget')).toBeInTheDocument();
expect(screen.queryByText('Todo List')).toBeNull();
expect(screen.queryByText('Vacay')).toBeNull();
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
await user.click(screen.getByRole('button', { name: 'On' }));
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.queryByText('Budget')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Off' }));
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
});
});
+59 -138
View File
@@ -1,7 +1,7 @@
import { useState, type ComponentType } from 'react'
import type { ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -76,8 +76,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
const [query, setQuery] = useState('')
const [view, setView] = useState<AddonView>('all')
if (loading) {
return (
@@ -196,10 +194,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
/* A card per type, the way every other admin tab sets its cards: Trip in the
left column, Global and Integration in the right one, each card with the
dialogs' head band carrying the type, its count and what it means. Above
them one card says how many are on and finds an addon by name. */
/* One column per type, side by side, instead of three stacked sections. Stacked, each
section opened its own set of columns and the tall tiles (Collab, Journey) set a
section's height while its neighbours ran out early. Side by side the column's
head carries the type, so the tiles need no badge of their own. */
const groups = [
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
@@ -216,145 +214,68 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
)
/** Whether an addon is shown under the search and the filter. */
const shown = (addon: Addon) => {
if (view === 'on' && !addon.enabled) return false
if (view === 'off' && addon.enabled) return false
const q = query.trim().toLocaleLowerCase()
if (!q) return true
const label = getAddonLabel(t, addon)
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
}
const card = (g: (typeof groups)[number]) => {
const meta = TYPE_META[g.key]
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
const visible = g.addons.filter(shown)
const ai = g.key === 'integration' && aiAddons.some(shown)
if (!visible.length && !ai) return null
const on = all.filter(a => a.enabled).length
return (
<SettingsCard
key={g.key}
icon={meta.icon}
title={t(meta.labelKey)}
hint={t(meta.hintKey)}
badge={(
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
</Tooltip>
)}
bodyClassName="p-3"
>
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{visible.map(tile)}
</div>
</SettingsCard>
)
}
const [first, ...rest] = groups
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
return (
<>
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
</SettingsCard>
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">{cards.start}</div>
<div className="min-w-0">{cards.end}</div>
</div>
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0 ? (
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
) : (
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
{t('admin.addons.noMatch')}
</p>
))}
</>
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
</div>
)}
</SettingsCard>
)
}
type AddonView = 'all' | 'on' | 'off'
/**
* The head card's body: a tile per type with how many of it are on, then a
* search over the addons' names and descriptions and a switch between all,
* the ones on and the ones off.
*/
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
groups: { key: string; addons: Addon[] }[]
query: string
onQuery: (q: string) => void
view: AddonView
onView: (v: AddonView) => void
/** The head band of one type's column: icon tile, the type, its count and what it means. */
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
label: string
hint: string
addons: Addon[]
t: (key: string, params?: Record<string, unknown>) => string
}) {
const enabled = addons.filter(a => a.enabled).length
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
const Icon = meta.icon
const on = g.addons.filter(a => a.enabled).length
return (
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
<Icon size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
</span>
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
</div>
)
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative flex min-w-0 flex-1 items-center">
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
<input
type="search"
value={query}
onChange={e => onQuery(e.target.value)}
placeholder={t('admin.addons.search')}
aria-label={t('admin.addons.search')}
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
style={fs(13, 'body')}
/>
</label>
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
{(['all', 'on', 'off'] as const).map(v => (
<button
key={v}
type="button"
onClick={() => onView(v)}
aria-pressed={view === v}
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}
>
{t(`admin.addons.filter.${v}`)}
</button>
))}
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
<Icon size={13} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
<span className="inline-flex">
<StatusPill>{enabled}/{addons.length}</StatusPill>
</span>
</Tooltip>
</div>
{/* Says what the type means: the only place that still explains it. */}
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
</div>
</>
</div>
)
}
@@ -102,13 +102,6 @@ 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)}>
@@ -11,7 +11,7 @@ import {
Trash2,
X,
} from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
@@ -79,7 +79,6 @@ export default function AssistantHeader({
const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate();
const menu = useContextMenu();
const moreRef = useRef<HTMLButtonElement>(null);
const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
@@ -96,14 +95,6 @@ export default function AssistantHeader({
run();
};
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
const closeMenu = () => {
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
menu.close();
if (fromMenu) moreRef.current?.focus();
};
const openMenu = (e: MouseEvent) =>
menu.open(
e,
@@ -196,12 +187,10 @@ export default function AssistantHeader({
</Tooltip>
<Tooltip label={t('assistant.window.menu')}>
<button
ref={moreRef}
type="button"
onClick={openMenu}
aria-label={t('assistant.window.menu')}
aria-haspopup="menu"
aria-expanded={menu.menu !== null}
className={ICON_BUTTON}
>
<Ellipsis size={14} aria-hidden="true" />
@@ -229,7 +218,6 @@ export default function AssistantHeader({
icon={null}
label={th.title}
active={th.id === thread.threadId}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-shrink-0 text-caption text-content-faint">
@@ -246,7 +234,7 @@ export default function AssistantHeader({
)}
</PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={closeMenu} />
<ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog
isOpen={confirming}
@@ -1,7 +1,7 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
import type { AssistantMessage as StoredMessage } from '@trek/shared';
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
import { Fragment, useMemo } from 'react';
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
import AssistantSources from './AssistantSources';
import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
import { messageBlocks } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import AssistantCardBlock from './cards/AssistantCardBlock';
import { useAssistantShare } from './useAssistantShare';
@@ -108,28 +108,6 @@ function Question({ message }: { message: StoredMessage }) {
);
}
/** One block of an answer, in the shape its kind takes. */
function AnswerBlock({
block,
message,
writing,
streaming,
}: {
block: MessageBlock;
message: StoredMessage;
writing: boolean;
streaming: boolean;
}) {
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
return <AssistantChangeCard items={block.items} />;
}
function Answer({ message }: { message: StoredMessage }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
@@ -141,16 +119,25 @@ function Answer({ message }: { message: StoredMessage }) {
const share = useAssistantShare(tripId, message, writing);
const last = blocks[blocks.length - 1];
const hasText = blocks.some((b) => b.kind === 'text');
const tail = tailStart(blocks);
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
{blocks.map((block, i) => (
<Fragment key={block.key}>
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
</Fragment>
))}
{blocks.map((block) => {
if (block.kind === 'text') {
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
}
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
if (block.kind === 'places') {
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
}
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
if (block.kind === 'remembered') {
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
}
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
{/* Nothing below says the answer goes on: no words being written, no step running. */}
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
<p role="status" className="m-0 text-caption">
@@ -1,5 +1,5 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
import { Check, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
@@ -25,24 +25,6 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
);
}
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
if (!strip.tour) return null;
return (
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
<button
type="button"
onClick={() => strip.addToTour(place)}
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
>
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
<span className="truncate">{t('assistant.place.addToTour')}</span>
</button>
</Tooltip>
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
@@ -203,9 +185,8 @@ export default function AssistantPlaceCard({
)}
</span>
</button>
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5">
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
<TourButton place={place} strip={strip} tall={tall} />
</div>
</li>
);
@@ -21,7 +21,6 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -106,7 +105,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks();
});
@@ -240,22 +238,3 @@ describe('AssistantPlaceStrip', () => {
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
});
});
describe('"+ Tour" on a found place (P6)', () => {
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
seed();
const insertFoundPlace = vi.fn(async () => 'held' as const);
useAssistantStore.setState({ insertFoundPlace });
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
});
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
seed();
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
});
});
@@ -30,7 +30,6 @@ 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.
@@ -44,9 +43,8 @@ export default function AssistantSurface({
onRevealMap,
bookVersion,
onOpenTripChat,
journal,
}),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat, journal]
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
@@ -1,6 +1,6 @@
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
import { useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
@@ -15,15 +15,13 @@ import { useAssistantAttach } from './useAssistantAttach';
import { useFollowSelectedDay } from './useAssistantFocus';
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
import { useAssistantSuggestions } from './useAssistantSuggestions';
import { useRevealAnswerTail } from './useRevealAnswerTail';
/**
* The chat itself: the messages, and under them the line that says what went
* wrong, the privacy note and the composer, held at the bottom while the
* messages scroll. A new question moves up to the top of the view, so its
* answer reads from its first line instead of chasing the newest one, and once
* the answer is written its end comes into view. A booking file or a place of
* the trip can be dropped anywhere on it.
* answer reads from its first line instead of chasing the newest one. A
* booking file or a place of the trip can be dropped anywhere on it.
*/
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const { t } = useTranslation();
@@ -33,8 +31,6 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
useFollowSelectedDay(tripId);
const viewport = useRef<HTMLDivElement>(null);
useRevealAnswerTail(viewport, isRunning);
const phone = variant === 'fullscreen';
const attach = useAssistantAttach(tripId);
@@ -56,11 +52,7 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
</span>
</div>
)}
<ThreadPrimitive.Viewport
ref={viewport}
turnAnchor="top"
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
>
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
<AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
{mentionable && (
@@ -1,5 +1,4 @@
import { ChevronRight, Luggage } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
@@ -9,17 +8,11 @@ import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicke
/**
* The user's trips to go on with in one trip's own chat, from the home chat's
* head: that chat, its history and its changes belong to the trip. The
* running and the next trip lead. The list opens before the trips are there,
* so the focus goes onto the first one once they come.
* running and the next trip lead.
*/
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation();
const trips = useAssistantTripPicker();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus({ preventScroll: true });
}, [trips]);
if (trips === null) {
return (
@@ -39,10 +32,9 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip, i) => (
{trips.map((trip) => (
<li key={trip.id}>
<button
ref={i === 0 ? first : undefined}
type="button"
onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
@@ -1,36 +0,0 @@
/**
* 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,40 +27,33 @@ export default function AssistantWelcome({
withSuggestions: boolean;
}) {
const { t } = useTranslation();
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 { 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 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">{readOnlyNote(scope, t)}</p>}
{!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>
)}
<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: WelcomeScope, t: (key: string) => string): string {
function welcomeTitle(scope: 'trip' | 'journey' | 'home', 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,
@@ -1,5 +1,5 @@
/**
* FE-AI-WIN-001 to FE-AI-WIN-016
* FE-AI-WIN-001 to FE-AI-WIN-014
*
* The desktop window around the chat: when it shows at all, the button it
* opens from, the home chat outside a trip with its way into one trip's chat
@@ -238,34 +238,6 @@ describe('AssistantWindow', () => {
expect(useAssistantStore.getState().window.open).toBe(true);
});
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(more).toHaveFocus();
expect(more).toHaveAttribute('aria-expanded', 'false');
// From the button the next Escape is the window's again.
fireEvent.keyDown(more, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
const button = screen.getByRole('button', { name: 'Chat about one trip' });
fireEvent.click(button);
// The list's own row, not the chat's "go on in Lisbon" beside it.
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
await waitFor(() => expect(lisbon).toHaveFocus());
fireEvent.keyDown(lisbon, { key: 'Escape' });
expect(button).toHaveFocus();
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false });
const onFocusDay = vi.fn();
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY, assistantScopeOf } from '../../store/assistantChatKey';
import { HOME_CHAT_KEY } 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 { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
import { useAssistantHome } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread';
@@ -33,9 +33,7 @@ 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. On a journey's
* page it opens on that journal's chat, the journey's conversation it shares
* with TREK Studio.
* chat opened that way has the way back home in its head.
*
* Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use.
@@ -172,31 +170,12 @@ 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);
@@ -219,14 +198,13 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
if (!open) setPicked(null);
}
const journal = useRouteJournalChat();
const { routeChat, current, onScreen, inPlanner } = windowChat(trip, journal, picked, home.chat);
const current = trip ?? 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 journal, its page); a tab opens on it.
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
const openPicked = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
else if (picked) void navigate(`${chatPagePath(picked)}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
},
[navigate, picked]
);
@@ -238,20 +216,19 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
[navigate]
);
const goHome = useCallback(() => setPicked(null), []);
// Another page leaves the trip or the journal: the chat stays on it, as if it had been picked.
// Another page leaves the trip: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page' && routeChat) setPicked(routeChat);
if (target.kind === 'page' && trip) setPicked(trip);
onOpen?.(target);
},
[onOpen, routeChat]
[onOpen, trip]
);
// 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 routeChat ? <AssistantLauncher variant="floating" tripId={launcherTrip} /> : null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : 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;
@@ -264,13 +241,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={inPlanner ? onFocusDay : undefined}
onRevealMap={inPlanner ? onRevealMap : undefined}
onFocusDay={onScreen ? onFocusDay : undefined}
onRevealMap={onScreen ? 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={launcherTrip} focusOnMount />}
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
</>
);
}
@@ -121,40 +121,4 @@ 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']);
});
});
@@ -107,7 +107,6 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: {
messageKey: 'assistant.error.generic',
@@ -61,7 +61,7 @@ export interface ChangeItem {
/** The answer cards of one group, side by side. */
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
export type MessageBlock =
type MessageBlock =
| { kind: 'text'; key: string; text: string }
| { kind: 'steps'; key: string; steps: ToolStep[] }
| { kind: 'change'; key: string; items: ChangeItem[] }
@@ -127,10 +127,6 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'propose_hike',
'create_hike',
'propose_tour',
'create_tour',
'create_paddle_tour',
'create_ski_tour',
'update_tour',
'propose_city_walk',
'create_city_walk',
@@ -138,9 +134,6 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'plan_ski_route',
'prepare_tour_change',
'save_tour_change',
'propose_round_hike',
'propose_hike_leg_way',
'import_wanderer_trail',
]);
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
@@ -462,18 +455,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
difficulty: { labelKey: 'assistant.change.label.difficulty' },
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' },
};
/**
* Where an answer's closing words and changes begin: the block after its last
* step, strip or card. That end is what the thread brings into view once the
* answer is written.
*/
export function tailStart(blocks: MessageBlock[]): number {
let i = blocks.length;
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
return i;
}
@@ -39,12 +39,6 @@ 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. */
@@ -1,5 +1,5 @@
/**
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
*
* The pure side of the book's page card: a page named as the Studio names
* it, a layout and a finding by their words, the findings worth a look, the
@@ -8,7 +8,7 @@
* the card carries it and the book is still at the trial's version), and
* where an applied change opens the Studio.
*/
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
@@ -67,9 +67,6 @@ describe('bookCardModel', () => {
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
'studio.templates.name.postcardHouses'
);
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
@@ -95,15 +92,6 @@ describe('bookCardModel', () => {
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
});
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
for (const code of BOOK_FINDING_CODES) {
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
}
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
expect(BOOK_FINDING_CODES).toContain(code);
}
});
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
const doc = book();
const [edit] = pageViewOf(card(), doc, 'pending');
@@ -10,7 +10,6 @@ import {
type BookSpread,
findBookGrid,
findBookTemplate,
findDecoratedTemplate,
setBookCover,
} from '@trek/shared';
@@ -58,15 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' },
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -92,7 +82,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
}
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
const template = findBookTemplate(layout.id);
return template ? t(template.labelKey) : null;
}
@@ -37,7 +37,6 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookAssets' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
},
entities: {
@@ -1,4 +1,4 @@
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react';
import { BookmarkMinus, BookmarkPlus, Copy, Ruler, Sparkles } from 'lucide-react';
import type { PackLook } from '../cardTypes';
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
verbs: {
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' },
setBookTheme: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setBookTheme' },
duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' },
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' },
@@ -24,24 +24,6 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
journal: { labelKey: 'assistant.card.bookDesign.style.journal' },
bold: { labelKey: 'assistant.card.bookDesign.style.bold' },
scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' },
// A design in one of the themes is called by the theme's own name.
'poster-pop': { labelKey: 'studio.theme.name.posterPop' },
cutout: { labelKey: 'studio.theme.name.cutout' },
postcard: { labelKey: 'studio.theme.name.postcard' },
riviera: { labelKey: 'studio.theme.name.riviera' },
'fabric-classic': { labelKey: 'studio.theme.name.fabricClassic' },
'minimal-serif': { labelKey: 'studio.theme.name.minimalSerif' },
'grid-badge': { labelKey: 'studio.theme.name.gridBadge' },
'scrapbook-script': { labelKey: 'studio.theme.name.scrapbookScript' },
tropical: { labelKey: 'studio.theme.name.tropical' },
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;

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