Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9fb0fc475 | |||
| 5596355141 | |||
| 9c83386655 | |||
| 98961dadd5 | |||
| 7ada99acfc | |||
| 2ac333f842 | |||
| 270a13ff60 | |||
| d800e64f93 | |||
| 390135f3bc | |||
| cad7d54e83 | |||
| 2df677d065 | |||
| 03493d4f79 | |||
| eae74c5605 | |||
| ec486214f1 | |||
| d09a09ad52 | |||
| 8b41561db9 | |||
| 73744203fb | |||
| bc385f27dd | |||
| 866b9e94af | |||
| ed7c79bcb7 | |||
| 1e4b618878 | |||
| 982861264e | |||
| 6434fe7a3c | |||
| 70e07f1965 | |||
| 87041c8842 | |||
| edb113f97f | |||
| fcd5f5dbb6 | |||
| 6849742d67 | |||
| 022e088da4 | |||
| 2471fc5aa7 | |||
| fad301ef5e | |||
| 1c83bf78fa | |||
| a6cd4ffdff | |||
| 988d27c39d | |||
| 62b954cffc | |||
| a2fed6afb8 | |||
| 97510e007e | |||
| 36f81934aa | |||
| 303bce431d | |||
| 73811e0bb7 | |||
| 0ca9f7241a | |||
| bab4c7edcd | |||
| 166b0d8ab3 | |||
| a5dbf6934c | |||
| d2bd6e7556 | |||
| 676d086ff2 | |||
| d8793f3d0b | |||
| fcf4c6e736 | |||
| fa40dfba2c | |||
| 2a17227337 | |||
| a5757dd59f | |||
| 2e10263155 | |||
| 7a7c66e801 | |||
| a55728fe46 | |||
| 8bd5e428b4 | |||
| 504b58ecf6 | |||
| 0f05b0342c | |||
| 7eea0e7669 | |||
| 0f88a74976 | |||
| 9ffd5e5ffb | |||
| 00a326029b | |||
| a47b35b965 | |||
| fcfcf2ff17 | |||
| 016567dd9a | |||
| b64dd700a8 | |||
| b0b6f5872f | |||
| e119a2c936 | |||
| 45ae8b5af2 | |||
| e28bd2fc99 | |||
| 1696162d22 | |||
| f375a2f3cc | |||
| 53bd21a7d7 | |||
| 67d8f22622 | |||
| 8433251c86 | |||
| 13449c3338 | |||
| 35c3ab4bd7 | |||
| 7356de5124 | |||
| 8a2cb10f7b | |||
| 0050072b76 | |||
| da43a81d26 | |||
| a955328070 | |||
| 53c05ab33b | |||
| 5160bd23d5 | |||
| 48ff1b3a98 | |||
| cf35dc7afd | |||
| 4b3340546f | |||
| 1d9648267b | |||
| c0ec8e567b | |||
| 2508fd8624 | |||
| de497d15a4 | |||
| 05666295e0 | |||
| 62da83f5f7 | |||
| b333910533 | |||
| 2d1da19152 | |||
| e7ac46c756 | |||
| f1a2174f96 | |||
| 89c33ac3f4 | |||
| aeda658f18 | |||
| 2f3a0219cd | |||
| 41fb5759e2 | |||
| 6a77496438 | |||
| 487f8dd886 | |||
| 09f286fbfe | |||
| 4cc62f4c4e | |||
| cb34c46226 | |||
| e777118272 | |||
| 3f2da7a867 | |||
| 9c3649f011 | |||
| 73fdbdc0c3 | |||
| 0879b5aecc | |||
| 9729b0b575 | |||
| 35acdb8ba1 | |||
| a19fdf6161 | |||
| 813dd7a7eb | |||
| d51b9de2b0 | |||
| 6254510e0f | |||
| c525b2f84c | |||
| b3d30a834e | |||
| 589d3354eb | |||
| 8ce3b24a01 | |||
| 06a1dcd730 | |||
| 4b633d8cd2 | |||
| 728a275fef | |||
| f6befaa6b2 | |||
| fd6d3fddd5 | |||
| 280e55bbc9 | |||
| e84060cdf7 | |||
| fbc85b05a1 | |||
| 01333c6ff3 | |||
| aecef03090 | |||
| 8dacd0a50c | |||
| 3c73da4427 | |||
| cff2e55db7 | |||
| 429a25faa9 | |||
| 486a2eeca3 | |||
| 26a6147e8d | |||
| a7b53988ff | |||
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e | |||
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 | |||
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 |
@@ -22,7 +22,6 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY shared/package.json ./shared/
|
COPY shared/package.json ./shared/
|
||||||
COPY client/package.json ./client/
|
COPY client/package.json ./client/
|
||||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
|
||||||
RUN npm ci --workspace=client
|
RUN npm ci --workspace=client
|
||||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
COPY client/ ./client/
|
COPY client/ ./client/
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
|
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
|||||||
|
|
||||||
#### 🧭 Planning
|
#### 🧭 Planning
|
||||||
|
|
||||||
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
|
- **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 and route lines. 3D buildings and terrain are Mapbox only
|
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
|
||||||
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
|
- **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
|
||||||
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
|
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
|
||||||
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
|
- **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
|
||||||
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
|
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
|
||||||
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
|
- **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
|
||||||
- **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
|
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
|
||||||
- **Public transport**: door-to-door itineraries over Transitous
|
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
|
||||||
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
|
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
|
||||||
- **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>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### 🧳 Bookings and money
|
#### 🥾 Tours
|
||||||
|
|
||||||
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
|
- **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)
|
||||||
- **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
|
- **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
|
||||||
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
|
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
|
||||||
- **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
|
- **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
|
||||||
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
|
- **Multi-day tours**: one route cut into stages and spread across the trip's days
|
||||||
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
|
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
|
||||||
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
|
- **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
|
||||||
- **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
|
- **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
|
||||||
- **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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🧳 Bookings and money
|
||||||
|
|
||||||
|
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
|
||||||
|
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||||
|
- **Accommodation**: a stay spans a range of nights with a check-in window
|
||||||
|
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
|
||||||
|
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
|
||||||
|
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
|
||||||
|
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
|
||||||
|
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
|
||||||
|
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
|
||||||
|
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### 👥 Collaboration
|
#### 👥 Collaboration
|
||||||
|
|
||||||
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
|
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
|
||||||
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
|
- **Members**: add by email or username, hand over ownership, 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
|
- **Permissions**: an admin maps each of 17 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
|
- **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 anyone can open without an account
|
- **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 with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
|
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
|
||||||
|
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
|
||||||
|
|
||||||
#### 📔 Journal, Atlas and Vacay
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
|
#### 📔 Journey and TREK Studio
|
||||||
- **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
|
- **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
|
||||||
- **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
|
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
|
||||||
|
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
|
||||||
|
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
|
||||||
|
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
|
||||||
|
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🗺️ Atlas, Vacay and Collections
|
||||||
|
|
||||||
|
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||||
|
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||||
|
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||||
|
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🤖 AI assistant and MCP
|
||||||
|
|
||||||
|
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
|
||||||
|
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
|
||||||
|
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
|
||||||
|
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
|
||||||
|
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
|
||||||
|
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### 🧩 Plugins
|
#### 🧩 Plugins
|
||||||
|
|
||||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
- **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, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
|
- **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
|
- **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
|
- **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) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
|
- **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
|
||||||
|
|
||||||
#### 🤖 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
|||||||
|
|
||||||
#### 📱 Mobile and offline
|
#### 📱 Mobile and offline
|
||||||
|
|
||||||
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
|
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
|
||||||
- **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
|
- **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 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 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**: 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 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**: pre-download a trip's raster tiles, and clear them again
|
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
|
||||||
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
|
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### ⚙️ Admin, accounts and security
|
#### ⚙️ Admin, accounts and security
|
||||||
|
|
||||||
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
|
- **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 (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
|
- **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
|
||||||
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
|
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
|
||||||
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
|
- **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
|
||||||
- **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 the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
|
||||||
- **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 and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
|
||||||
- **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
|
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
|
||||||
- **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
|
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
|
|||||||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||||||
/** The hit targets over the spread, one per element. */
|
/** The hit targets over the spread, one per element. */
|
||||||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
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> {
|
async function studioSection(page: Page, name: string): Promise<void> {
|
||||||
await railButton(page, name).click()
|
await railButton(page, name).click()
|
||||||
@@ -1300,7 +1314,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: p => p.locator('.st-back'),
|
target: p => p.locator('.st-head-close'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1458,16 +1472,162 @@ 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': {
|
'studio-travel': {
|
||||||
guide: guide('studio-travel'),
|
guide: guide('studio-travel'),
|
||||||
start: openStudio,
|
start: openStudio,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
target: p => railButton(p, 'Travel'),
|
target: p => railButton(p, 'Travel'),
|
||||||
act: async p => { await studioSection(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)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: sidePanel,
|
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(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
"prebuild": "node scripts/generate-icons.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:analyze": "vite build --mode analyze",
|
"build:analyze": "vite build --mode analyze",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -41,9 +41,7 @@
|
|||||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||||
"e2e:report": "playwright show-report",
|
"e2e:report": "playwright show-report",
|
||||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
"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": {
|
"dependencies": {
|
||||||
"@assistant-ui/react": "0.15.25",
|
"@assistant-ui/react": "0.15.25",
|
||||||
@@ -94,7 +92,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"mapbox-gl": "^3.31.0",
|
"mapbox-gl": "^3.31.0",
|
||||||
"maplibre-gl": "5.24.0",
|
"maplibre-gl": "6.15.0",
|
||||||
"modern-screenshot": "4.7.0",
|
"modern-screenshot": "4.7.0",
|
||||||
"plyr": "^3.8.4",
|
"plyr": "^3.8.4",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 385,
|
"@typescript-eslint/no-non-null-assertion": 384,
|
||||||
"@typescript-eslint/no-misused-promises": 849,
|
"@typescript-eslint/no-misused-promises": 849,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 222,
|
"complexity": 221,
|
||||||
"@typescript-eslint/no-explicit-any": 146,
|
"@typescript-eslint/no-explicit-any": 146,
|
||||||
"trek/no-swallowed-catch": 130,
|
"trek/no-swallowed-catch": 130,
|
||||||
"max-params": 32,
|
"max-params": 32,
|
||||||
"trek/store-without-selector": 67,
|
"trek/store-without-selector": 67,
|
||||||
"react-hooks/refs": 556,
|
"react-hooks/refs": 555,
|
||||||
"react-hooks/set-state-in-effect": 194,
|
"react-hooks/set-state-in-effect": 194,
|
||||||
"@typescript-eslint/no-floating-promises": 165,
|
"@typescript-eslint/no-floating-promises": 164,
|
||||||
"react-hooks/exhaustive-deps": 116,
|
"react-hooks/exhaustive-deps": 116,
|
||||||
"@typescript-eslint/no-unused-vars": 124,
|
"@typescript-eslint/no-unused-vars": 124,
|
||||||
"react-hooks/immutability": 28,
|
"react-hooks/immutability": 28,
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"tests": {
|
"tests": {
|
||||||
"@typescript-eslint/no-explicit-any": 490,
|
"@typescript-eslint/no-explicit-any": 490,
|
||||||
"@typescript-eslint/no-non-null-assertion": 1931,
|
"@typescript-eslint/no-non-null-assertion": 1927,
|
||||||
"@typescript-eslint/no-misused-promises": 22,
|
"@typescript-eslint/no-misused-promises": 22,
|
||||||
"@typescript-eslint/no-unused-vars": 51,
|
"@typescript-eslint/no-unused-vars": 51,
|
||||||
"@typescript-eslint/no-floating-promises": 33,
|
"@typescript-eslint/no-floating-promises": 33,
|
||||||
|
|||||||
@@ -661,9 +661,24 @@
|
|||||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Settings / Demo account locks",
|
||||||
|
"hooks": ["src/components/Demo/useDemoLock.ts"],
|
||||||
|
"desktop": [
|
||||||
|
"src/components/Settings/AccountDemoLock.tsx",
|
||||||
|
"src/components/Settings/DisplaySettingsTab.tsx",
|
||||||
|
"src/components/Settings/IntegrationsTab.tsx",
|
||||||
|
"src/components/Settings/ai/AiConnectionSection.tsx"
|
||||||
|
],
|
||||||
|
"mobile": [
|
||||||
|
"src/mobile/screens/settings/MAiConnectionSection.tsx",
|
||||||
|
"src/mobile/screens/settings/MSettingsGeneral.tsx",
|
||||||
|
"src/mobile/screens/settings/MSettingsMcp.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Settings / Account",
|
"feature": "Settings / Account",
|
||||||
"hooks": ["src/components/Settings/useAccountSettings.ts"],
|
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
|
||||||
"desktop": ["src/components/Settings/AccountTab.tsx"],
|
"desktop": ["src/components/Settings/AccountTab.tsx"],
|
||||||
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
"scripts/check-gl-split.mjs",
|
"scripts/check-gl-split.mjs",
|
||||||
"scripts/check-page-pattern.mjs",
|
"scripts/check-page-pattern.mjs",
|
||||||
"scripts/generate-icons.mjs",
|
"scripts/generate-icons.mjs",
|
||||||
"scripts/patch-maplibre.mjs",
|
|
||||||
"src/App.test.tsx",
|
"src/App.test.tsx",
|
||||||
"src/App.tsx",
|
"src/App.tsx",
|
||||||
"src/App.versionHandover.test.tsx",
|
"src/App.versionHandover.test.tsx",
|
||||||
@@ -409,7 +408,6 @@
|
|||||||
"src/components/Journey/usePhotoZoom.ts",
|
"src/components/Journey/usePhotoZoom.ts",
|
||||||
"src/components/Layout/BottomNav.test.tsx",
|
"src/components/Layout/BottomNav.test.tsx",
|
||||||
"src/components/Layout/BottomNav.tsx",
|
"src/components/Layout/BottomNav.tsx",
|
||||||
"src/components/Layout/DemoBanner.tsx",
|
|
||||||
"src/components/Layout/InAppNotificationBell.test.tsx",
|
"src/components/Layout/InAppNotificationBell.test.tsx",
|
||||||
"src/components/Layout/InAppNotificationBell.tsx",
|
"src/components/Layout/InAppNotificationBell.tsx",
|
||||||
"src/components/Layout/MobileTopBar.test.tsx",
|
"src/components/Layout/MobileTopBar.test.tsx",
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ export const DYNAMIC_ALLOWED = [
|
|||||||
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
|
'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',
|
'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_:-]+)+$/;
|
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||||
"src/components/Studio/StudioShell.tsx": 1,
|
"src/components/Studio/StudioShell.tsx": 1,
|
||||||
"src/components/Studio/TravelElements.tsx": 1,
|
|
||||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||||
|
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||||
"src/components/Todo/useTodoList.ts": 1,
|
"src/components/Todo/useTodoList.ts": 1,
|
||||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||||
"src/components/shared/CountryFlag.tsx": 1,
|
"src/components/shared/CountryFlag.tsx": 1,
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync } from 'node:fs'
|
|
||||||
import { createRequire } from 'node:module'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
|
||||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
|
||||||
|
|
||||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
|
||||||
|
|
||||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
|
||||||
const loops = [
|
|
||||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
|
||||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const filename of [
|
|
||||||
'src/util/dom.ts',
|
|
||||||
'dist/maplibre-gl.js',
|
|
||||||
'dist/maplibre-gl-dev.js',
|
|
||||||
'dist/maplibre-gl-csp.js',
|
|
||||||
'dist/maplibre-gl-csp-dev.js',
|
|
||||||
]) {
|
|
||||||
const target = join(dirname(packagePath), filename)
|
|
||||||
const code = readFileSync(target, 'utf8')
|
|
||||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
|
||||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
|
||||||
const [before, after] = loop
|
|
||||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
|
||||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
|
||||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
|
||||||
}
|
|
||||||
writeFileSync(target, code.replace(before, after))
|
|
||||||
}
|
|
||||||
@@ -6,17 +6,17 @@
|
|||||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||||
"src/components/Map/MapView.tsx": 1399,
|
"src/components/Map/MapView.tsx": 1399,
|
||||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||||
"src/components/Map/MapViewGL.tsx": 2290,
|
"src/components/Map/MapViewGL.tsx": 2290,
|
||||||
"src/components/Map/RouteCalculator.ts": 1076,
|
"src/components/Map/RouteCalculator.ts": 1076,
|
||||||
"src/components/PDF/TripPDF.tsx": 1082,
|
"src/components/PDF/TripPDF.tsx": 1082,
|
||||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||||
"src/components/Planner/DayPlanSidebar.tsx": 2953,
|
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||||
"src/components/Studio/TravelElements.tsx": 1738,
|
"src/components/Studio/TravelElements.tsx": 1559,
|
||||||
"src/components/Studio/countryShapes.ts": 2595,
|
"src/components/Studio/countryShapes.ts": 2595,
|
||||||
"src/index.css": 1636,
|
"src/index.css": 1636,
|
||||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||||
@@ -26,5 +26,5 @@
|
|||||||
"src/pages/atlas/useAtlas.ts": 1211,
|
"src/pages/atlas/useAtlas.ts": 1211,
|
||||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||||
"src/styles/studio.css": 2009
|
"src/styles/studio.css": 1918
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,7 @@
|
|||||||
"components/Journey/journeyMapPopup.ts": 17,
|
"components/Journey/journeyMapPopup.ts": 17,
|
||||||
"components/Journey/moodConfig.ts": 5,
|
"components/Journey/moodConfig.ts": 5,
|
||||||
"components/Layout/BottomNav.tsx": 3,
|
"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/MobileTopBar.tsx": 51,
|
||||||
"components/Layout/Navbar.tsx": 21,
|
"components/Layout/Navbar.tsx": 21,
|
||||||
"components/Layout/NewVersionNotice.tsx": 3,
|
"components/Layout/NewVersionNotice.tsx": 3,
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
"components/Map/poiCategories.ts": 7,
|
"components/Map/poiCategories.ts": 7,
|
||||||
"components/Map/ratingBadge.ts": 2,
|
"components/Map/ratingBadge.ts": 2,
|
||||||
"components/Map/reservationsMapbox.ts": 1,
|
"components/Map/reservationsMapbox.ts": 1,
|
||||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
|
||||||
"components/PDF/TripPDF.tsx": 53,
|
"components/PDF/TripPDF.tsx": 53,
|
||||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||||
@@ -208,7 +206,7 @@
|
|||||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||||
"mobile/screens/settings/MSettings.tsx": 2,
|
"mobile/screens/settings/MSettings.tsx": 2,
|
||||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||||
"mobile/screens/settings/MSettingsAccount.tsx": 21,
|
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||||
@@ -295,7 +293,6 @@
|
|||||||
"pages/FilesPage.tsx": 11,
|
"pages/FilesPage.tsx": 11,
|
||||||
"pages/ForgotPasswordPage.tsx": 18,
|
"pages/ForgotPasswordPage.tsx": 18,
|
||||||
"pages/HelpPage.tsx": 21,
|
"pages/HelpPage.tsx": 21,
|
||||||
"pages/InAppNotificationsPage.tsx": 9,
|
|
||||||
"pages/JoinTripPage.tsx": 4,
|
"pages/JoinTripPage.tsx": 4,
|
||||||
"pages/JourneyDetailPage.tsx": 53,
|
"pages/JourneyDetailPage.tsx": 53,
|
||||||
"pages/JourneyPage.tsx": 64,
|
"pages/JourneyPage.tsx": 64,
|
||||||
|
|||||||
@@ -44,9 +44,10 @@
|
|||||||
"components/Studio/SpreadView.tsx": 1,
|
"components/Studio/SpreadView.tsx": 1,
|
||||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||||
"components/Studio/StudioSwatches.tsx": 1,
|
"components/Studio/StudioSwatches.tsx": 1,
|
||||||
"components/Studio/TravelElements.tsx": 6,
|
"components/Studio/TravelElements.tsx": 3,
|
||||||
"components/Studio/peerColour.ts": 1,
|
"components/Studio/peerColour.ts": 1,
|
||||||
"components/Studio/printSheets.ts": 4,
|
"components/Studio/printSheets.ts": 4,
|
||||||
|
"components/Studio/travel/badgeLook.ts": 2,
|
||||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||||
"components/Vacay/VacayPersons.tsx": 1,
|
"components/Vacay/VacayPersons.tsx": 1,
|
||||||
"components/shared/DialogShell.tsx": 2,
|
"components/shared/DialogShell.tsx": 2,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type AiApiKeyConnectRequest,
|
type AiApiKeyConnectRequest,
|
||||||
type AiAuthStartResponse,
|
type AiAuthStartResponse,
|
||||||
type AiChatgptFinishRequest,
|
type AiChatgptFinishRequest,
|
||||||
type AiClaudeConnectorResponse,
|
|
||||||
type AiConnectionScope,
|
type AiConnectionScope,
|
||||||
type AiConnectionView,
|
type AiConnectionView,
|
||||||
type AiInstanceConnectionRequest,
|
type AiInstanceConnectionRequest,
|
||||||
@@ -233,7 +232,3 @@ export const aiAdminApi = {
|
|||||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
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),
|
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,4 +712,33 @@ describe('AddonManager', () => {
|
|||||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||||
expect(tours.querySelector('svg.lucide-puzzle')).not.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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComponentType } from 'react'
|
import { useState, type ComponentType } from 'react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useIsDark } from '../../hooks/useIsDark'
|
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 } from 'lucide-react'
|
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 EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import DawarichIcon from '../shared/DawarichIcon'
|
import DawarichIcon from '../shared/DawarichIcon'
|
||||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||||
@@ -76,6 +76,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
toggleToursWanderer,
|
toggleToursWanderer,
|
||||||
mapSwitches,
|
mapSwitches,
|
||||||
} = useAddonManager()
|
} = useAddonManager()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [view, setView] = useState<AddonView>('all')
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -194,10 +196,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
/* A card per type, the way every other admin tab sets its cards: Trip in the
|
||||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
left column, Global and Integration in the right one, each card with the
|
||||||
section's height while its neighbours ran out early. Side by side the column's
|
dialogs' head band carrying the type, its count and what it means. Above
|
||||||
head carries the type, so the tiles need no badge of their own. */
|
them one card says how many are on and finds an addon by name. */
|
||||||
const groups = [
|
const groups = [
|
||||||
{ key: 'trip', addons: tripAddons },
|
{ key: 'trip', addons: tripAddons },
|
||||||
{ key: 'global', addons: globalAddons },
|
{ key: 'global', addons: globalAddons },
|
||||||
@@ -214,7 +216,50 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
</span>
|
</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 (
|
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
|
<SettingsCard
|
||||||
icon={Puzzle}
|
icon={Puzzle}
|
||||||
title={t('admin.addons.title')}
|
title={t('admin.addons.title')}
|
||||||
@@ -227,55 +272,89 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
>
|
>
|
||||||
{addons.length === 0 ? (
|
{addons.length === 0
|
||||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
? <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} />}
|
||||||
<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>
|
</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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
type AddonView = 'all' | 'on' | 'off'
|
||||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
|
||||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
/**
|
||||||
label: string
|
* The head card's body: a tile per type with how many of it are on, then a
|
||||||
hint: string
|
* search over the addons' names and descriptions and a switch between all,
|
||||||
addons: Addon[]
|
* 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
|
||||||
t: (key: string, params?: Record<string, unknown>) => string
|
t: (key: string, params?: Record<string, unknown>) => string
|
||||||
}) {
|
}) {
|
||||||
const enabled = addons.filter(a => a.enabled).length
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||||
<Icon size={13} strokeWidth={2} />
|
{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>
|
||||||
<div className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
|
||||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
|
||||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
|
||||||
<span className="inline-flex">
|
|
||||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
|
||||||
</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>
|
</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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
</ul>
|
</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') {
|
if (block.kind === 'h4') {
|
||||||
return (
|
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)}>
|
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||||
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||||
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { Fragment, useMemo } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
|
|||||||
import AssistantSources from './AssistantSources';
|
import AssistantSources from './AssistantSources';
|
||||||
import AssistantToolSteps from './AssistantToolSteps';
|
import AssistantToolSteps from './AssistantToolSteps';
|
||||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||||
import { messageBlocks } from './assistantParts';
|
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
|
||||||
import { useAssistantView } from './assistantViewContext';
|
import { useAssistantView } from './assistantViewContext';
|
||||||
import AssistantCardBlock from './cards/AssistantCardBlock';
|
import AssistantCardBlock from './cards/AssistantCardBlock';
|
||||||
import { useAssistantShare } from './useAssistantShare';
|
import { useAssistantShare } from './useAssistantShare';
|
||||||
@@ -108,6 +108,28 @@ 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 }) {
|
function Answer({ message }: { message: StoredMessage }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { tripId, variant } = useAssistantView();
|
const { tripId, variant } = useAssistantView();
|
||||||
@@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) {
|
|||||||
const share = useAssistantShare(tripId, message, writing);
|
const share = useAssistantShare(tripId, message, writing);
|
||||||
const last = blocks[blocks.length - 1];
|
const last = blocks[blocks.length - 1];
|
||||||
const hasText = blocks.some((b) => b.kind === 'text');
|
const hasText = blocks.some((b) => b.kind === 'text');
|
||||||
|
const tail = tailStart(blocks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||||
{blocks.map((block) => {
|
{blocks.map((block, i) => (
|
||||||
if (block.kind === 'text') {
|
<Fragment key={block.key}>
|
||||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
{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} />
|
||||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
</Fragment>
|
||||||
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. */}
|
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||||
<p role="status" className="m-0 text-caption">
|
<p role="status" className="m-0 text-caption">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function AssistantSurface({
|
|||||||
onRevealMap,
|
onRevealMap,
|
||||||
bookVersion,
|
bookVersion,
|
||||||
onOpenTripChat,
|
onOpenTripChat,
|
||||||
|
journal,
|
||||||
}: AssistantSurfaceProps) {
|
}: AssistantSurfaceProps) {
|
||||||
const runtime = useAssistantRuntime(tripId);
|
const runtime = useAssistantRuntime(tripId);
|
||||||
// The phone is always compact; a window is what its width says.
|
// The phone is always compact; a window is what its width says.
|
||||||
@@ -43,8 +44,9 @@ export default function AssistantSurface({
|
|||||||
onRevealMap,
|
onRevealMap,
|
||||||
bookVersion,
|
bookVersion,
|
||||||
onOpenTripChat,
|
onOpenTripChat,
|
||||||
|
journal,
|
||||||
}),
|
}),
|
||||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat, journal]
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||||
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||||
@@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach';
|
|||||||
import { useFollowSelectedDay } from './useAssistantFocus';
|
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||||
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||||
|
import { useRevealAnswerTail } from './useRevealAnswerTail';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat itself: the messages, and under them the line that says what went
|
* 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
|
* 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
|
* 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. A
|
* answer reads from its first line instead of chasing the newest one, and once
|
||||||
* booking file or a place of the trip can be dropped anywhere on it.
|
* the answer is written its end comes into view. A booking file or a place of
|
||||||
|
* the trip can be dropped anywhere on it.
|
||||||
*/
|
*/
|
||||||
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
|||||||
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||||
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||||
useFollowSelectedDay(tripId);
|
useFollowSelectedDay(tripId);
|
||||||
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
useRevealAnswerTail(viewport, isRunning);
|
||||||
|
|
||||||
const phone = variant === 'fullscreen';
|
const phone = variant === 'fullscreen';
|
||||||
const attach = useAssistantAttach(tripId);
|
const attach = useAssistantAttach(tripId);
|
||||||
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
<ThreadPrimitive.Viewport
|
||||||
|
ref={viewport}
|
||||||
|
turnAnchor="top"
|
||||||
|
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
|
||||||
|
>
|
||||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||||
{mentionable && (
|
{mentionable && (
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-WELCOME-001 to FE-AI-WELCOME-002
|
||||||
|
*
|
||||||
|
* The empty chat asks what fits the chat it is: a journey's conversation
|
||||||
|
* shown for its journal asks about the journal, the same conversation in TREK
|
||||||
|
* Studio about the book, and each tells a reader what they may not change.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import AssistantWelcome from './AssistantWelcome';
|
||||||
|
import { AssistantViewContext } from './assistantViewContext';
|
||||||
|
|
||||||
|
const JOURNAL_KEY = -12;
|
||||||
|
|
||||||
|
function welcome(canEdit: boolean, journal?: boolean) {
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={{ tripId: JOURNAL_KEY, variant: 'window', journal }}>
|
||||||
|
<AssistantWelcome suggestions={[]} canEdit={canEdit} withSuggestions={false} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AssistantWelcome', () => {
|
||||||
|
it("FE-AI-WELCOME-001: a journey's chat shown for its journal asks about the journal", () => {
|
||||||
|
welcome(false, true);
|
||||||
|
expect(screen.getByText('What should go into the journal?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/You can only read this journal/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-WELCOME-002: the same chat in the Studio asks about the book', () => {
|
||||||
|
welcome(true);
|
||||||
|
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/You can only/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,33 +27,40 @@ export default function AssistantWelcome({
|
|||||||
withSuggestions: boolean;
|
withSuggestions: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { tripId, variant } = useAssistantView();
|
const { tripId, variant, journal } = useAssistantView();
|
||||||
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
|
// The Studio's chat is about a book, not a plan; the journey's chat shown for its journal about the journal,
|
||||||
const scope = useAssistantScope().kind;
|
// the home chat about all of the user's travels.
|
||||||
const book = scope === 'journey';
|
const about = useAssistantScope().kind;
|
||||||
|
const scope: WelcomeScope = about === 'journey' && journal ? 'journal' : about;
|
||||||
const hints = useAssistantHints(tripId, { withStays: true });
|
const hints = useAssistantHints(tripId, { withStays: true });
|
||||||
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||||
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
<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)} />
|
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||||
{!canEdit && (
|
{!canEdit && <p className="m-0 max-w-xs text-center text-caption text-content-muted">{readOnlyNote(scope, t)}</p>}
|
||||||
<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'} />
|
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
|
||||||
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
|
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
|
||||||
</div>
|
</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. */
|
/** The question over the empty chat, by what the chat is about. */
|
||||||
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
|
function welcomeTitle(scope: WelcomeScope, t: (key: string) => string): string {
|
||||||
if (scope === 'journey') return t('journey.studio.assistant.welcome');
|
if (scope === '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');
|
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({
|
export function AssistantSuggestions({
|
||||||
suggestions,
|
suggestions,
|
||||||
layout,
|
layout,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import type { WindowRect } from '../../hooks/useDragResize';
|
import type { WindowRect } from '../../hooks/useDragResize';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
import { HOME_CHAT_KEY, assistantScopeOf } from '../../store/assistantChatKey';
|
||||||
import { useAssistantStore } from '../../store/assistantStore';
|
import { useAssistantStore } from '../../store/assistantStore';
|
||||||
import AssistantFrameEdges from './AssistantFrameEdges';
|
import AssistantFrameEdges from './AssistantFrameEdges';
|
||||||
import AssistantHeader from './AssistantHeader';
|
import AssistantHeader from './AssistantHeader';
|
||||||
@@ -15,7 +15,7 @@ import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
|
|||||||
import { plannerSearchOf } from './assistantShowOnMap';
|
import { plannerSearchOf } from './assistantShowOnMap';
|
||||||
import type { AssistantChatTrip } from './assistantViewContext';
|
import type { AssistantChatTrip } from './assistantViewContext';
|
||||||
import type { EntityTarget } from './cards/cardTypes';
|
import type { EntityTarget } from './cards/cardTypes';
|
||||||
import { useAssistantHome } from './useAssistantHome';
|
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
||||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||||
import { useAssistantThread } from './useAssistantThread';
|
import { useAssistantThread } from './useAssistantThread';
|
||||||
|
|
||||||
@@ -33,7 +33,9 @@ import { useAssistantThread } from './useAssistantThread';
|
|||||||
*
|
*
|
||||||
* Outside a trip it opens on the home chat, which knows all of the user's
|
* 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
|
* trips; its head lists them to go on in one trip's own chat, and a trip's
|
||||||
* chat opened that way has the way back home in its head.
|
* chat opened that way has the way back home in its head. On a journey's
|
||||||
|
* page it opens on that journal's chat, the journey's conversation it shares
|
||||||
|
* with TREK Studio.
|
||||||
*
|
*
|
||||||
* Mounted once for the whole app; it shows nothing until the assistant has a
|
* Mounted once for the whole app; it shows nothing until the assistant has a
|
||||||
* connection this user can use.
|
* connection this user can use.
|
||||||
@@ -170,12 +172,31 @@ function ChatWindow({
|
|||||||
onFocusDay={onFocusDay}
|
onFocusDay={onFocusDay}
|
||||||
onRevealMap={onRevealMap}
|
onRevealMap={onRevealMap}
|
||||||
onOpenTripChat={onOpenTripChat}
|
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>
|
</div>
|
||||||
</section>
|
</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) {
|
export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap }: AssistantWindowProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const available = useAssistantStore((s) => s.status?.available === true);
|
const available = useAssistantStore((s) => s.status?.available === true);
|
||||||
@@ -198,13 +219,14 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
|||||||
if (!open) setPicked(null);
|
if (!open) setPicked(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = trip ?? picked ?? home.chat;
|
const journal = useRouteJournalChat();
|
||||||
|
const { routeChat, current, onScreen, inPlanner } = windowChat(trip, journal, picked, home.chat);
|
||||||
const atHome = current.id === HOME_CHAT_KEY;
|
const atHome = current.id === HOME_CHAT_KEY;
|
||||||
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
|
// A change in a trip the planner does not show is shown by opening that trip (a journal, its page); a tab opens on it.
|
||||||
const openPicked = useCallback(
|
const openPicked = useCallback(
|
||||||
(target: EntityTarget) => {
|
(target: EntityTarget) => {
|
||||||
if (target.kind === 'page') void navigate(target.path);
|
if (target.kind === 'page') void navigate(target.path);
|
||||||
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
|
else if (picked) void navigate(`${chatPagePath(picked)}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
|
||||||
},
|
},
|
||||||
[navigate, picked]
|
[navigate, picked]
|
||||||
);
|
);
|
||||||
@@ -216,19 +238,20 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
const goHome = useCallback(() => setPicked(null), []);
|
const goHome = useCallback(() => setPicked(null), []);
|
||||||
// Another page leaves the trip: the chat stays on it, as if it had been picked.
|
// Another page leaves the trip or the journal: the chat stays on it, as if it had been picked.
|
||||||
const openOnScreen = useCallback(
|
const openOnScreen = useCallback(
|
||||||
(target: EntityTarget) => {
|
(target: EntityTarget) => {
|
||||||
if (target.kind === 'page' && trip) setPicked(trip);
|
if (target.kind === 'page' && routeChat) setPicked(routeChat);
|
||||||
onOpen?.(target);
|
onOpen?.(target);
|
||||||
},
|
},
|
||||||
[onOpen, trip]
|
[onOpen, routeChat]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The round button's hint dot reads a trip's plan; a journal has none.
|
||||||
|
const launcherTrip = trip?.id ?? null;
|
||||||
if (!available) return null;
|
if (!available) return null;
|
||||||
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
|
if (!open) return routeChat ? <AssistantLauncher variant="floating" tripId={launcherTrip} /> : null;
|
||||||
|
|
||||||
const onScreen = !!trip && current.id === trip.id;
|
|
||||||
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
|
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
|
||||||
if (atHome) openTarget = openFromHome;
|
if (atHome) openTarget = openFromHome;
|
||||||
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
|
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
|
||||||
@@ -241,13 +264,13 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
|||||||
hidden={minimized}
|
hidden={minimized}
|
||||||
onOpen={openTarget}
|
onOpen={openTarget}
|
||||||
// Only the planner on screen has days to select and a map to bring back.
|
// Only the planner on screen has days to select and a map to bring back.
|
||||||
onFocusDay={onScreen ? onFocusDay : undefined}
|
onFocusDay={inPlanner ? onFocusDay : undefined}
|
||||||
onRevealMap={onScreen ? onRevealMap : undefined}
|
onRevealMap={inPlanner ? onRevealMap : undefined}
|
||||||
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
||||||
tripSwitch={tripSwitch}
|
tripSwitch={tripSwitch}
|
||||||
/>
|
/>
|
||||||
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
||||||
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
|
{minimized && <AssistantLauncher variant="floating" tripId={launcherTrip} focusOnMount />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,4 +121,40 @@ describe('the questions an answer offers', () => {
|
|||||||
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
expect(followUpsFor(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']);
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export interface ChangeItem {
|
|||||||
/** The answer cards of one group, side by side. */
|
/** The answer cards of one group, side by side. */
|
||||||
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
|
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
|
||||||
|
|
||||||
type MessageBlock =
|
export type MessageBlock =
|
||||||
| { kind: 'text'; key: string; text: string }
|
| { kind: 'text'; key: string; text: string }
|
||||||
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
||||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||||
@@ -463,4 +463,17 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
|||||||
track: { labelKey: 'assistant.change.label.track' },
|
track: { labelKey: 'assistant.change.label.track' },
|
||||||
packing: { labelKey: 'assistant.change.label.packing' },
|
packing: { labelKey: 'assistant.change.label.packing' },
|
||||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
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,6 +39,12 @@ export interface AssistantView {
|
|||||||
* chat's card that offers it; left out where no chat can be switched to.
|
* chat's card that offers it; left out where no chat can be switched to.
|
||||||
*/
|
*/
|
||||||
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
|
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. */
|
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
|
|||||||
nordic: { labelKey: 'studio.theme.name.nordic' },
|
nordic: { labelKey: 'studio.theme.name.nordic' },
|
||||||
alpine: { labelKey: 'studio.theme.name.alpine' },
|
alpine: { labelKey: 'studio.theme.name.alpine' },
|
||||||
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
|
'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;
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ describe('cardLooks', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-AI-LOOK-003b: a file kept from the chat opens the trip Files and shows nothing on the map', () => {
|
||||||
|
const kept: AssistantApplied = { toolCallId: 'k', entity: 'file', ids: [], dayId: null, undoUntil: null };
|
||||||
|
expect(ENTITY_LOOK.file.open?.(kept, null)).toEqual({ kind: 'tab', tab: 'dateien' });
|
||||||
|
expect(ENTITY_LOOK.file.onMap).toBeUndefined();
|
||||||
|
expect(ENTITY_LOOK.file.openLabelKey).toBe('assistant.card.file.open');
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
|
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
|
||||||
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
|
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
|
||||||
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
|
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
CalendarMinus,
|
CalendarMinus,
|
||||||
CalendarPlus,
|
CalendarPlus,
|
||||||
Circle,
|
Circle,
|
||||||
|
FileSearch,
|
||||||
|
FileText,
|
||||||
|
FolderInput,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Luggage,
|
Luggage,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -63,6 +66,8 @@ export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
|
|||||||
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
|
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
|
||||||
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
|
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
|
||||||
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
|
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
|
||||||
|
readAttachment: { Icon: FileSearch, tone: 'info', labelKey: 'assistant.change.verb.readAttachment' },
|
||||||
|
keepFile: { Icon: FolderInput, tone: 'success', labelKey: 'assistant.change.verb.keepFile' },
|
||||||
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
|
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
|
||||||
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
|
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
|
||||||
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
|
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
|
||||||
@@ -159,6 +164,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
|||||||
},
|
},
|
||||||
search_help: { stepKey: 'assistant.step.help' },
|
search_help: { stepKey: 'assistant.step.help' },
|
||||||
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
|
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
|
||||||
|
read_attached_file: { stepKey: 'assistant.step.readingAttachment' },
|
||||||
...TOUR_LOOK.steps,
|
...TOUR_LOOK.steps,
|
||||||
...ROADTRIP_LOOK.steps,
|
...ROADTRIP_LOOK.steps,
|
||||||
...BUDGET_LOOK.steps,
|
...BUDGET_LOOK.steps,
|
||||||
@@ -193,6 +199,8 @@ export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook
|
|||||||
assignment: { Icon: CalendarDays, onMap: BY_DAY },
|
assignment: { Icon: CalendarDays, onMap: BY_DAY },
|
||||||
day_note: { Icon: StickyNote, onMap: BY_DAY },
|
day_note: { Icon: StickyNote, onMap: BY_DAY },
|
||||||
memory: { Icon: NotebookPen },
|
memory: { Icon: NotebookPen },
|
||||||
|
// A file kept from the chat: it opens the trip's Files.
|
||||||
|
file: { Icon: FileText, openLabelKey: 'assistant.card.file.open', open: () => ({ kind: 'tab', tab: 'dateien' }) },
|
||||||
other: { Icon: Circle },
|
other: { Icon: Circle },
|
||||||
...TOUR_LOOK.entities,
|
...TOUR_LOOK.entities,
|
||||||
...ROADTRIP_LOOK.entities,
|
...ROADTRIP_LOOK.entities,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ArrowRight, MessageSquareText } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../../i18n';
|
||||||
|
import { type AssistantChatTrip, useAssistantView } from '../../assistantViewContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the home chat's cards that go on in another chat share: the question
|
||||||
|
* handed over, waiting there ready to send, and the button that switches to
|
||||||
|
* that chat. The button shows only where a chat can be switched to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The question that waits in the other chat's composer. */
|
||||||
|
export function HandedQuestion({ question }: { question: string | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!question) return null;
|
||||||
|
return (
|
||||||
|
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
||||||
|
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
||||||
|
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
||||||
|
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The button that goes on in the chat under `chat`, with the question handed over. */
|
||||||
|
export function OpenChatButton({
|
||||||
|
chat,
|
||||||
|
question,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
chat: AssistantChatTrip;
|
||||||
|
question: string | null;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const { onOpenTripChat, variant } = useAssistantView();
|
||||||
|
if (!onOpenTripChat) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTripChat(chat, question)}
|
||||||
|
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
||||||
|
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-JOURNALCHAT-001 to FE-AI-JOURNALCHAT-003
|
||||||
|
*
|
||||||
|
* The home chat's card that offers a journal's own chat: the journal with
|
||||||
|
* its subtitle or days and what it holds, the question waiting there, and the
|
||||||
|
* button that goes on in the chat kept under the journey's key.
|
||||||
|
*/
|
||||||
|
import type { JournalChatCard } from '@trek/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||||
|
import { AssistantViewContext } from '../../assistantViewContext';
|
||||||
|
import { JournalChatBlock } from './JournalChatBlock';
|
||||||
|
|
||||||
|
const JAPAN: JournalChatCard = {
|
||||||
|
kind: 'journalChat',
|
||||||
|
journey: {
|
||||||
|
id: 12,
|
||||||
|
title: 'Japan 2026',
|
||||||
|
subtitle: null,
|
||||||
|
startDate: '2026-11-02',
|
||||||
|
endDate: '2026-11-16',
|
||||||
|
entryCount: 4,
|
||||||
|
photoCount: 120,
|
||||||
|
},
|
||||||
|
question: 'Make a new entry for Berlin Hauptbahnhof',
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawn(
|
||||||
|
cards: JournalChatCard[],
|
||||||
|
onOpenTripChat?: (chat: { id: number; title: string }, q: string | null) => void
|
||||||
|
) {
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={{ tripId: 0, variant: 'fullscreen', onOpenTripChat }}>
|
||||||
|
<JournalChatBlock cards={cards.map((card, index) => ({ toolCallId: `j${index}`, card }))} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JournalChatBlock', () => {
|
||||||
|
it('FE-AI-JOURNALCHAT-001: shows the journal, its days, what it holds and the question', () => {
|
||||||
|
drawn([JAPAN]);
|
||||||
|
expect(screen.getByRole('region', { name: 'Continue in a journal' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('4 entries')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('120 photos')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Make a new entry for Berlin Hauptbahnhof')).toBeInTheDocument();
|
||||||
|
// Nowhere to switch to: no button.
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FE-AI-JOURNALCHAT-002: the button goes on in the journal's chat, under the journey's key", () => {
|
||||||
|
const open = vi.fn();
|
||||||
|
drawn([JAPAN], open);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: "Continue in this journal's chat" }));
|
||||||
|
expect(open).toHaveBeenCalledWith({ id: -12, title: 'Japan 2026' }, 'Make a new entry for Berlin Hauptbahnhof');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNALCHAT-003: a subtitle stands for the days, an empty journal shows no photos, nothing draws nothing', () => {
|
||||||
|
const bare: JournalChatCard = {
|
||||||
|
...JAPAN,
|
||||||
|
journey: { ...JAPAN.journey, subtitle: 'Autumn', startDate: null, endDate: null, entryCount: 0, photoCount: 0 },
|
||||||
|
question: null,
|
||||||
|
};
|
||||||
|
drawn([bare]);
|
||||||
|
expect(screen.getByText('Autumn')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0 entries')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/photos?$/)).not.toBeInTheDocument();
|
||||||
|
const { container } = render(<JournalChatBlock cards={[]} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { JournalChatCard } from '@trek/shared';
|
||||||
|
import { BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../../i18n';
|
||||||
|
import { assistantChatKey } from '../../../../store/assistantChatKey';
|
||||||
|
import { WhiteBadge } from '../../../Planner/planParts';
|
||||||
|
import type { CardEntry } from '../cardTypes';
|
||||||
|
import CardFrame from '../parts/CardFrame';
|
||||||
|
import CardHeader from '../parts/CardHeader';
|
||||||
|
import { useCardFormat } from '../parts/useCardFormat';
|
||||||
|
import { chatDates } from './chatDates';
|
||||||
|
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home chat's way onto a journal's own page: the journal under its head
|
||||||
|
* (its subtitle or days, how many entries and photos it holds), the question
|
||||||
|
* that waits there ready to send, and the button that goes on in the
|
||||||
|
* journal's chat. That chat is the journey's conversation on its page, kept
|
||||||
|
* under the journey's key; the window switches to it in place, the phone
|
||||||
|
* opens the journal with its chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function JournalFacts({ card }: { card: JournalChatCard }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { entryCount, photoCount } = card.journey;
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
<WhiteBadge>{t('assistant.card.journey.entries', { count: entryCount })}</WhiteBadge>
|
||||||
|
{photoCount > 0 && <WhiteBadge>{t('assistant.card.journey.photos', { count: photoCount })}</WhiteBadge>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JournalChatSection({ card, first }: { card: JournalChatCard; first: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const format = useCardFormat();
|
||||||
|
const { journey, question } = card;
|
||||||
|
const meta = journey.subtitle ?? chatDates(t, format.date, journey.startDate, journey.endDate);
|
||||||
|
const chat = { id: assistantChatKey({ kind: 'journey', journeyId: journey.id }), title: journey.title };
|
||||||
|
return (
|
||||||
|
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||||
|
{first ? (
|
||||||
|
<CardHeader
|
||||||
|
icon={BookOpen}
|
||||||
|
tone="info"
|
||||||
|
eyebrow={t('assistant.card.journalChat.eyebrow')}
|
||||||
|
title={journey.title}
|
||||||
|
meta={meta}
|
||||||
|
>
|
||||||
|
<JournalFacts card={card} />
|
||||||
|
</CardHeader>
|
||||||
|
) : (
|
||||||
|
<div className="mb-2.5">
|
||||||
|
<bdi className="block truncate text-body font-bold text-content">{journey.title}</bdi>
|
||||||
|
<div className="mt-0.5 text-caption text-content-muted">{meta}</div>
|
||||||
|
<JournalFacts card={card} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<HandedQuestion question={question} />
|
||||||
|
<OpenChatButton chat={chat} question={question} label={t('assistant.card.journalChat.open')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JournalChatBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const journals = cards.flatMap((entry) => (entry.card.kind === 'journalChat' ? [entry.card] : []));
|
||||||
|
if (journals.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<CardFrame label={t('assistant.card.journalChat.label')}>
|
||||||
|
{journals.map((card, index) => (
|
||||||
|
<JournalChatSection key={`${card.journey.id}-${index}`} card={card} first={index === 0} />
|
||||||
|
))}
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { TripChatCard, TripChatPhase } from '@trek/shared';
|
import type { TripChatCard, TripChatPhase } from '@trek/shared';
|
||||||
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
|
import { Luggage } from 'lucide-react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||||
import { useAssistantView } from '../../assistantViewContext';
|
|
||||||
import type { CardEntry } from '../cardTypes';
|
import type { CardEntry } from '../cardTypes';
|
||||||
import CardFrame from '../parts/CardFrame';
|
import CardFrame from '../parts/CardFrame';
|
||||||
import CardHeader from '../parts/CardHeader';
|
import CardHeader from '../parts/CardHeader';
|
||||||
import { useCardFormat } from '../parts/useCardFormat';
|
import { useCardFormat } from '../parts/useCardFormat';
|
||||||
|
import { chatDates } from './chatDates';
|
||||||
|
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The home chat's way into one trip's own chat: the trip under its head (its
|
* The home chat's way into one trip's own chat: the trip under its head (its
|
||||||
@@ -27,18 +28,9 @@ const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: stri
|
|||||||
|
|
||||||
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
|
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { onOpenTripChat, variant } = useAssistantView();
|
|
||||||
const format = useCardFormat();
|
const format = useCardFormat();
|
||||||
const { trip, question } = card;
|
const { trip, question } = card;
|
||||||
const start = trip.startDate ?? trip.endDate;
|
const dates = chatDates(t, format.date, trip.startDate, trip.endDate);
|
||||||
const end = trip.endDate ?? trip.startDate;
|
|
||||||
let dates = t('assistant.card.tripChat.noDates');
|
|
||||||
if (start && end) {
|
|
||||||
dates =
|
|
||||||
start === end
|
|
||||||
? format.date(start)
|
|
||||||
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
|
|
||||||
}
|
|
||||||
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
|
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
|
||||||
return (
|
return (
|
||||||
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||||
@@ -59,25 +51,12 @@ function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }
|
|||||||
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{question && (
|
<HandedQuestion question={question} />
|
||||||
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
<OpenChatButton
|
||||||
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
chat={{ id: trip.id, title: trip.title }}
|
||||||
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
question={question}
|
||||||
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
label={t('assistant.card.tripChat.open')}
|
||||||
</p>
|
/>
|
||||||
)}
|
|
||||||
{onOpenTripChat && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
|
|
||||||
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
|
||||||
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('assistant.card.tripChat.open')}
|
|
||||||
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* The days a card of another chat names under its head: one day, a span, or
|
||||||
|
* that it has none yet. Pure, so the trip's card and the journal's read one
|
||||||
|
* wording.
|
||||||
|
*/
|
||||||
|
export function chatDates(
|
||||||
|
t: (key: string, params?: Record<string, string>) => string,
|
||||||
|
date: (value: string) => string,
|
||||||
|
startDate: string | null,
|
||||||
|
endDate: string | null
|
||||||
|
): string {
|
||||||
|
const start = startDate ?? endDate;
|
||||||
|
const end = endDate ?? startDate;
|
||||||
|
if (!start || !end) return t('assistant.card.tripChat.noDates');
|
||||||
|
if (start === end) return date(start);
|
||||||
|
return t('assistant.card.tripChat.dates', { start: date(start), end: date(end) });
|
||||||
|
}
|
||||||
@@ -13,9 +13,11 @@ export const HOME_LOOK = {
|
|||||||
steps: {
|
steps: {
|
||||||
list_trips: { stepKey: 'assistant.step.readingTrips' },
|
list_trips: { stepKey: 'assistant.step.readingTrips' },
|
||||||
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
|
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
|
||||||
|
open_journey_chat: { stepKey: 'assistant.step.openJournalChat' },
|
||||||
},
|
},
|
||||||
entities: {},
|
entities: {},
|
||||||
cards: {
|
cards: {
|
||||||
tripChat: {},
|
tripChat: {},
|
||||||
|
journalChat: {},
|
||||||
},
|
},
|
||||||
} satisfies PackLook<'tripChat'>;
|
} satisfies PackLook<'tripChat' | 'journalChat'>;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { CardView } from '../cardTypes';
|
import type { CardView } from '../cardTypes';
|
||||||
|
import { JournalChatBlock } from './JournalChatBlock';
|
||||||
import { TripChatBlock } from './TripChatBlock';
|
import { TripChatBlock } from './TripChatBlock';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the home chat's kind draws: the trip it offers to go on with, beside
|
* How the home chat's kinds draw: the trip or the journal it offers to go on
|
||||||
* the answer. This file only maps the kind to its view; the component lives
|
* with, beside the answer. This file only maps the kind to its view; the component lives
|
||||||
* in a sibling file, since a file that exports anything besides components
|
* in a sibling file, since a file that exports anything besides components
|
||||||
* breaks fast refresh.
|
* breaks fast refresh.
|
||||||
*/
|
*/
|
||||||
export const HOME_VIEWS = {
|
export const HOME_VIEWS = {
|
||||||
tripChat: { Block: TripChatBlock },
|
tripChat: { Block: TripChatBlock },
|
||||||
} satisfies { [K in 'tripChat']: CardView<K> };
|
journalChat: { Block: JournalChatBlock },
|
||||||
|
} satisfies { [K in 'tripChat' | 'journalChat']: CardView<K> };
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import type { AssistantChangeRow, JourneyEntriesCard } from '@trek/shared';
|
import type { AssistantChangeRow, JourneyEntriesCard } from '@trek/shared';
|
||||||
import { AlignLeft, CalendarDays, Clock, MapPin, Tag, Type } from 'lucide-react';
|
import {
|
||||||
|
AlignLeft,
|
||||||
|
ArrowDownUp,
|
||||||
|
CalendarDays,
|
||||||
|
Clock,
|
||||||
|
LocateFixed,
|
||||||
|
MapPin,
|
||||||
|
Tag,
|
||||||
|
Tags,
|
||||||
|
ThumbsDown,
|
||||||
|
ThumbsUp,
|
||||||
|
Type,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
|
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||||
import type { ChangeItem } from '../../assistantParts';
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
import { FactChange } from '../parts/CardFacts';
|
import { FactChange } from '../parts/CardFacts';
|
||||||
import { EntryMarks, EntryStory, EntryThumb } from './JourneyEntryParts';
|
import { EntryMarks, EntryStory, EntryThumb } from './JourneyEntryParts';
|
||||||
@@ -20,7 +33,9 @@ import { entryHoverProps, useEntryHover } from './useEntryHover';
|
|||||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||||
|
|
||||||
/** The facts a journal change row names on its faint line, in this order: when, then where. */
|
/** The facts a journal change row names on its faint line, in this order: when, then where. */
|
||||||
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin } as const;
|
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin, coordinates: LocateFixed } as const;
|
||||||
|
/** The lists an update rewrites, each by its glyph: the tags and the two sides of the verdict. */
|
||||||
|
const LIST_ICONS: Readonly<Record<string, typeof Tag>> = { tags: Tags, pros: ThumbsUp, cons: ThumbsDown };
|
||||||
/** The words an update rewrites, named only when they change: the entry's title and its text. */
|
/** The words an update rewrites, named only when they change: the entry's title and its text. */
|
||||||
const WORD_ICONS = { name: Type, text: AlignLeft } as const;
|
const WORD_ICONS = { name: Type, text: AlignLeft } as const;
|
||||||
type FactRow = AssistantChangeRow & { labelKey: keyof typeof FACT_ICONS };
|
type FactRow = AssistantChangeRow & { labelKey: keyof typeof FACT_ICONS };
|
||||||
@@ -45,7 +60,9 @@ function StateChange({ row }: { row: AssistantChangeRow }) {
|
|||||||
if (flag) return <FactChange icon={flag.Icon} before={null} after={t(flag.labelKey)} tone="info" />;
|
if (flag) return <FactChange icon={flag.Icon} before={null} after={t(flag.labelKey)} tone="info" />;
|
||||||
const was = stateOf(row.field, row.before);
|
const was = stateOf(row.field, row.before);
|
||||||
const now = stateOf(row.field, row.after);
|
const now = stateOf(row.field, row.after);
|
||||||
const Icon = (now ?? was)?.Icon ?? Tag;
|
const listed =
|
||||||
|
row.field && Object.prototype.hasOwnProperty.call(LIST_ICONS, row.field) ? LIST_ICONS[row.field] : undefined;
|
||||||
|
const Icon = (now ?? was)?.Icon ?? listed ?? Tag;
|
||||||
const named = (tone: typeof now, value: string | null) => (tone ? t(tone.labelKey) : value);
|
const named = (tone: typeof now, value: string | null) => (tone ? t(tone.labelKey) : value);
|
||||||
return (
|
return (
|
||||||
<FactChange
|
<FactChange
|
||||||
@@ -110,12 +127,32 @@ export function JourneyEntryAside({ card, item }: { card: JourneyEntriesCard; it
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A new order: the entries that move, in their new places, each with its day; the rest counted. */
|
||||||
|
function NewOrder({ card }: { card: JourneyEntriesCard }) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
return (
|
||||||
|
<ol className="m-0 mt-1 list-none space-y-0.5 p-0">
|
||||||
|
{card.entries.map((look, index) => (
|
||||||
|
<li key={look.entryId ?? index} className="flex items-center gap-1.5 text-caption text-content-secondary">
|
||||||
|
<ArrowDownUp size={11} aria-hidden="true" className="flex-none text-info" />
|
||||||
|
<bdi className="min-w-0 truncate">{look.title ?? look.place ?? cardDateLabel(look.date, locale)}</bdi>
|
||||||
|
<span className="flex-none text-content-faint">{cardDateLabel(look.date, locale)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{card.more > 0 && (
|
||||||
|
<li className="text-caption text-content-faint">{t('assistant.card.common.more', { count: card.more })}</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The start of the entry's text and its marks, under the row; resting on them shows its place on the map. */
|
/** The start of the entry's text and its marks, under the row; resting on them shows its place on the map. */
|
||||||
export function JourneyEntryBody({ card, item }: { card: JourneyEntriesCard; item: ChangeItem }) {
|
export function JourneyEntryBody({ card, item }: { card: JourneyEntriesCard; item: ChangeItem }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hover = useEntryHover(card.journeyId, item.toolCallId);
|
const hover = useEntryHover(card.journeyId, item.toolCallId);
|
||||||
const look = card.entries[0];
|
const look = card.entries[0];
|
||||||
if (!look || GONE.has(item.state)) return null;
|
if (!look || GONE.has(item.state)) return null;
|
||||||
|
if (card.change === 'reordered') return <NewOrder card={card} />;
|
||||||
return (
|
return (
|
||||||
<div {...entryHoverProps(hover, look)}>
|
<div {...entryHoverProps(hover, look)}>
|
||||||
<EntryStory look={look} roomy={false} />
|
<EntryStory look={look} roomy={false} />
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-JOURNEY-020 to FE-AI-JOURNEY-022
|
||||||
|
*
|
||||||
|
* The parts of a journal entry the chat added later: its tags and how its
|
||||||
|
* verdict leans among its marks, an update's tags, verdict and coordinates
|
||||||
|
* old to new, and a new order of the entries drawn as the entries that move.
|
||||||
|
*/
|
||||||
|
import type { AssistantChangePreview, JourneyEntriesCard, JourneyEntryLook } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { render, screen } from '../../../../../tests/helpers/render';
|
||||||
|
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||||
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
|
import { AssistantViewContext } from '../../assistantViewContext';
|
||||||
|
|
||||||
|
const ENTRY: JourneyEntryLook = {
|
||||||
|
entryId: 12,
|
||||||
|
type: 'entry',
|
||||||
|
title: 'Sunset at the Spree',
|
||||||
|
date: '2026-10-21',
|
||||||
|
time: null,
|
||||||
|
place: null,
|
||||||
|
point: null,
|
||||||
|
countryCode: null,
|
||||||
|
story: null,
|
||||||
|
mood: null,
|
||||||
|
weather: null,
|
||||||
|
photoId: null,
|
||||||
|
video: false,
|
||||||
|
photoCount: 0,
|
||||||
|
visibility: 'private',
|
||||||
|
draft: false,
|
||||||
|
fromTripPlace: false,
|
||||||
|
dayIndex: null,
|
||||||
|
tags: ['river', 'evening'],
|
||||||
|
verdict: { pros: ['the light'], cons: ['cold', 'wind'], prosCount: 1, consCount: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function journal(entries: JourneyEntryLook[], change: JourneyEntriesCard['change'], more = 0): JourneyEntriesCard {
|
||||||
|
return { kind: 'journeyEntries', journeyId: 3, journalTitle: 'Berlin', tripJournal: false, change, entries, more };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pending(
|
||||||
|
card: JourneyEntriesCard,
|
||||||
|
verbKey: AssistantChangePreview['verbKey'],
|
||||||
|
rows: AssistantChangePreview['rows']
|
||||||
|
) {
|
||||||
|
const preview: AssistantChangePreview = {
|
||||||
|
toolCallId: 'e1',
|
||||||
|
verbKey,
|
||||||
|
subject: 'Berlin',
|
||||||
|
rows,
|
||||||
|
dayId: null,
|
||||||
|
placeIds: [],
|
||||||
|
undoable: true,
|
||||||
|
domain: 'journey',
|
||||||
|
card,
|
||||||
|
};
|
||||||
|
const item: ChangeItem = { toolCallId: 'e1', preview, applied: null, state: 'pending', card };
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>
|
||||||
|
<AssistantChangeCard items={[item]} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the later parts of a journal entry', () => {
|
||||||
|
it('FE-AI-JOURNEY-020: a new entry shows its tags and how its verdict leans', () => {
|
||||||
|
pending(journal([ENTRY], 'new'), 'addJournalEntry', []);
|
||||||
|
expect(screen.getByText('river')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('evening')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1 good point')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2 weak points')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEY-021: an update names its tags, each side of its verdict and its coordinates, old to new', () => {
|
||||||
|
const rows: AssistantChangePreview['rows'] = [
|
||||||
|
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
|
||||||
|
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
|
||||||
|
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
|
||||||
|
];
|
||||||
|
pending(journal([{ ...ENTRY, tags: [], verdict: null }], 'changed'), 'updateJournalEntry', rows);
|
||||||
|
expect(screen.getByText('river, evening')).toHaveClass('line-through');
|
||||||
|
expect(screen.getByText('cold')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('52.5200, 13.4050')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEY-022: a new order lists the entries that move in their new places, the rest counted', () => {
|
||||||
|
const moved = [
|
||||||
|
{ ...ENTRY, entryId: 2, title: 'Museum Island', moved: true },
|
||||||
|
{ ...ENTRY, entryId: 1, title: 'Arrival', moved: true },
|
||||||
|
];
|
||||||
|
pending(journal(moved, 'reordered', 3), 'reorderJournalEntries', []);
|
||||||
|
expect(screen.getByText('Put the entries in a new order')).toBeInTheDocument();
|
||||||
|
const text = document.body.textContent ?? '';
|
||||||
|
expect(text.indexOf('Museum Island')).toBeGreaterThan(-1);
|
||||||
|
expect(text.indexOf('Museum Island')).toBeLessThan(text.indexOf('Arrival'));
|
||||||
|
expect(screen.getByText('3 more')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { JourneyEntryLook } from '@trek/shared';
|
import type { JourneyEntryLook } from '@trek/shared';
|
||||||
import { CalendarDays, Globe, Images, MapPin, Play, Users } from 'lucide-react';
|
import { CalendarDays, Globe, Images, MapPin, Play, Tag, ThumbsDown, ThumbsUp, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useTranslation } from '../../../../i18n';
|
import { useTranslation } from '../../../../i18n';
|
||||||
import { cardDateLabel, cardPlace } from '../../../Journey/journeyCard';
|
import { cardDateLabel, cardPlace } from '../../../Journey/journeyCard';
|
||||||
@@ -101,11 +101,42 @@ function WeatherMark({ weather }: { weather: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first. */
|
/** Its tags and how its verdict leans: the good and the weak points counted. */
|
||||||
|
function ListMarks({ look }: { look: JourneyEntryLook }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const verdict = look.verdict ?? null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(look.tags ?? []).map((tag, index) => (
|
||||||
|
<SoftPill
|
||||||
|
key={`${tag}-${index}`}
|
||||||
|
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
|
||||||
|
>
|
||||||
|
<bdi className="truncate">{tag}</bdi>
|
||||||
|
</SoftPill>
|
||||||
|
))}
|
||||||
|
{verdict && verdict.prosCount > 0 && (
|
||||||
|
<SoftPill tone="success" icon={<ThumbsUp size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||||
|
{t('assistant.card.journey.pros', { count: verdict.prosCount })}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
{verdict && verdict.consCount > 0 && (
|
||||||
|
<SoftPill tone="warning" icon={<ThumbsDown size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||||
|
{t('assistant.card.journey.cons', { count: verdict.consCount })}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first, its tags and verdict. */
|
||||||
export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mood = moodOf(look.mood) !== null;
|
const mood = moodOf(look.mood) !== null;
|
||||||
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2) return null;
|
const lists = (look.tags?.length ?? 0) > 0 || !!look.verdict;
|
||||||
|
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2 && !lists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<CardFacts>
|
<CardFacts>
|
||||||
<VisibilityMark visibility={look.visibility} />
|
<VisibilityMark visibility={look.visibility} />
|
||||||
@@ -117,6 +148,7 @@ export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
|||||||
{t('assistant.card.journey.photos', { count: look.photoCount })}
|
{t('assistant.card.journey.photos', { count: look.photoCount })}
|
||||||
</SoftPill>
|
</SoftPill>
|
||||||
)}
|
)}
|
||||||
|
<ListMarks look={look} />
|
||||||
</CardFacts>
|
</CardFacts>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-JOURNEYHEADER-001 to FE-AI-JOURNEYHEADER-005
|
||||||
|
*
|
||||||
|
* A change of a journal itself in the chat: the settings an update sets by
|
||||||
|
* the journal's own names, old to new; the trips under the row with the one
|
||||||
|
* the change links or unlinks marked; what a new journal brings and what a
|
||||||
|
* deletion or an unlink takes; the receipt that opens the journal.
|
||||||
|
*/
|
||||||
|
import type { AssistantApplied, AssistantChangePreview, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||||
|
import { useAssistantStore } from '../../../../store/assistantStore';
|
||||||
|
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||||
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
|
import { AssistantViewContext } from '../../assistantViewContext';
|
||||||
|
import { headerValueKey, settingRows } from './journeyHeaderModel';
|
||||||
|
|
||||||
|
const ASSISTANT = useAssistantStore.getState();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useAssistantStore.setState(ASSISTANT, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOKYO: JourneyHeaderTrip = {
|
||||||
|
id: 5,
|
||||||
|
title: 'Tokyo days',
|
||||||
|
startDate: '2026-11-02',
|
||||||
|
endDate: '2026-11-08',
|
||||||
|
change: 'kept',
|
||||||
|
};
|
||||||
|
|
||||||
|
const JAPAN: JourneyHeaderCard = {
|
||||||
|
kind: 'journeyHeader',
|
||||||
|
journeyId: 12,
|
||||||
|
title: 'Japan 2026',
|
||||||
|
subtitle: null,
|
||||||
|
change: 'changed',
|
||||||
|
trips: [TOKYO],
|
||||||
|
entries: 3,
|
||||||
|
photos: 40,
|
||||||
|
suggestions: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function change(
|
||||||
|
state: ChangeItem['state'],
|
||||||
|
card: JourneyHeaderCard,
|
||||||
|
over: Partial<AssistantChangePreview> = {}
|
||||||
|
): ChangeItem {
|
||||||
|
const preview: AssistantChangePreview = {
|
||||||
|
toolCallId: 'h1',
|
||||||
|
verbKey: 'updateJournalSettings',
|
||||||
|
subject: 'Japan 2026',
|
||||||
|
rows: [],
|
||||||
|
dayId: null,
|
||||||
|
placeIds: [],
|
||||||
|
undoable: true,
|
||||||
|
domain: 'journey',
|
||||||
|
card,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
const applied: AssistantApplied = { toolCallId: 'h1', entity: 'journey', ids: [12], dayId: null, undoUntil: null };
|
||||||
|
return { toolCallId: 'h1', preview, applied: state === 'applied' ? applied : null, state, card };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shown(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={{ tripId: -12, variant: 'window', onOpen }}>
|
||||||
|
<AssistantChangeCard items={[item]} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a change of a journal itself', () => {
|
||||||
|
it('FE-AI-JOURNEYHEADER-001: an update names each setting by the journal own words, old to new', () => {
|
||||||
|
const rows: AssistantChangePreview['rows'] = [
|
||||||
|
{ op: 'change', labelKey: 'name', before: 'Japan', after: 'Japan 2026' },
|
||||||
|
{ op: 'change', labelKey: 'field', field: 'show_mood', before: '✓', after: '✗' },
|
||||||
|
{ op: 'change', labelKey: 'field', field: 'status_override', before: '', after: 'live' },
|
||||||
|
];
|
||||||
|
shown(change('pending', JAPAN, { rows }));
|
||||||
|
expect(screen.getByText("Change the journal's settings")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Japan')).toHaveClass('line-through');
|
||||||
|
expect(screen.getByText('Mood: On')).toHaveClass('line-through');
|
||||||
|
expect(screen.getByText('Mood: Off')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Shown as: Automatic')).toHaveClass('line-through');
|
||||||
|
expect(screen.getByText('Shown as: Live')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tokyo days')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEYHEADER-002: a new journal shows its subtitle, the trips it links and the places that come in', () => {
|
||||||
|
const fresh: JourneyHeaderCard = {
|
||||||
|
...JAPAN,
|
||||||
|
journeyId: null,
|
||||||
|
change: 'new',
|
||||||
|
subtitle: 'Autumn',
|
||||||
|
trips: [{ ...TOKYO, change: 'link' }],
|
||||||
|
entries: null,
|
||||||
|
photos: null,
|
||||||
|
suggestions: 9,
|
||||||
|
};
|
||||||
|
shown(change('pending', fresh, { verbKey: 'createJournal', undoable: true }));
|
||||||
|
expect(screen.getByText('Start a journal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Autumn')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Will be added')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('9 places come in as suggestions')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEYHEADER-003: a deletion warns that everything goes; an unlink says what stays and what goes', () => {
|
||||||
|
const first = shown(change('pending', { ...JAPAN, change: 'willDelete' }, { verbKey: 'deleteJournal' }));
|
||||||
|
expect(screen.getByText('Delete the journal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/This cannot be undone\./)).toBeInTheDocument();
|
||||||
|
first.unmount();
|
||||||
|
const unlink: JourneyHeaderCard = {
|
||||||
|
...JAPAN,
|
||||||
|
trips: [{ ...TOKYO, change: 'unlink' }],
|
||||||
|
entries: 1,
|
||||||
|
suggestions: 4,
|
||||||
|
};
|
||||||
|
shown(change('pending', unlink, { verbKey: 'unlinkJournalTrip' }));
|
||||||
|
expect(screen.getByText('Tokyo days')).toHaveClass('line-through');
|
||||||
|
expect(screen.getByText('Will be taken out')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1 written entry stays')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('4 open suggestions go')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEYHEADER-004: the receipt opens the journal, a change that failed shows its row alone', () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const applied = shown(change('applied', JAPAN), onOpen);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open the journal' }));
|
||||||
|
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/12' });
|
||||||
|
applied.unmount();
|
||||||
|
shown(change('failed', JAPAN));
|
||||||
|
expect(screen.queryByText('Tokyo days')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEYHEADER-005: the model reads a status by its name and a switch as on or off', () => {
|
||||||
|
expect(headerValueKey('status', 'archived')).toBe('journey.status.archived');
|
||||||
|
expect(headerValueKey('status_override', null)).toBe('journey.settings.statusAuto');
|
||||||
|
expect(headerValueKey('show_mood', '✓')).toBe('settings.on');
|
||||||
|
expect(headerValueKey('cover_gradient', 'linear-gradient(red, blue)')).toBeNull();
|
||||||
|
expect(headerValueKey('status', 'toString')).toBeNull();
|
||||||
|
const rows: AssistantChangePreview['rows'] = [
|
||||||
|
{ op: 'add', labelKey: 'field', field: 'trip', before: null, after: 'Kyoto' },
|
||||||
|
{ op: 'add', labelKey: 'field', field: 'show_weather', before: null, after: '✓' },
|
||||||
|
{ op: 'add', labelKey: 'field', field: 'unknown', before: null, after: 'x' },
|
||||||
|
{ op: 'change', labelKey: 'name', before: 'A', after: 'B' },
|
||||||
|
];
|
||||||
|
expect(settingRows(rows).map((row) => row.field)).toEqual(['show_weather']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { AssistantChangeRow, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
|
||||||
|
import { AlignLeft, Luggage, Type } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../../i18n';
|
||||||
|
import { type PillTone, SoftPill } from '../../../Planner/planParts';
|
||||||
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
|
import { FactChange } from '../parts/CardFacts';
|
||||||
|
import { useCardFormat } from '../parts/useCardFormat';
|
||||||
|
import { headerFieldOf, headerValueKey, settingRows } from './journeyHeaderModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a change row of a journal itself adds to the row it already is (its
|
||||||
|
* verb and the journal's name): on the faint line the name, subtitle and
|
||||||
|
* settings it changes, each old to new; under the row the journal's trips,
|
||||||
|
* the one the change links or unlinks marked, and what the change brings or
|
||||||
|
* takes along. A change that failed, was discarded or ran out shows the row
|
||||||
|
* alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||||
|
const WORD_ICONS = { name: Type, description: AlignLeft } as const;
|
||||||
|
const NOTE = 'm-0 mt-1.5 text-caption';
|
||||||
|
|
||||||
|
const TRIP_TONE: Readonly<Record<JourneyHeaderTrip['change'], PillTone>> = {
|
||||||
|
kept: 'neutral',
|
||||||
|
link: 'success',
|
||||||
|
unlink: 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One setting a change sets, by its name, old to new. */
|
||||||
|
function SettingChange({ row }: { row: AssistantChangeRow }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const look = headerFieldOf(row.field);
|
||||||
|
if (!look) return null;
|
||||||
|
const named = (value: string | null) => {
|
||||||
|
const key = headerValueKey(row.field, value);
|
||||||
|
if (key) return t(key);
|
||||||
|
return value || null;
|
||||||
|
};
|
||||||
|
const before = row.op === 'add' ? null : named(row.before);
|
||||||
|
const after = named(row.after) ?? t('assistant.card.common.removed');
|
||||||
|
const name = t(look.labelKey);
|
||||||
|
return (
|
||||||
|
<FactChange
|
||||||
|
icon={look.Icon}
|
||||||
|
before={before === null ? null : t('assistant.card.journeyHeader.setting', { name, value: before })}
|
||||||
|
after={t('assistant.card.journeyHeader.setting', { name, value: after })}
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The journal's new name and subtitle and the settings a change sets. */
|
||||||
|
export function JourneyHeaderFacts({ item }: { card: JourneyHeaderCard; item: ChangeItem }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const rows = item.preview?.rows ?? [];
|
||||||
|
const words = rows.filter(
|
||||||
|
(row): row is AssistantChangeRow & { labelKey: keyof typeof WORD_ICONS } =>
|
||||||
|
row.op === 'change' && row.labelKey in WORD_ICONS
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{words.map((row) => (
|
||||||
|
<FactChange
|
||||||
|
key={row.labelKey}
|
||||||
|
icon={WORD_ICONS[row.labelKey]}
|
||||||
|
before={row.before || null}
|
||||||
|
after={row.after || t('assistant.card.common.removed')}
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{settingRows(rows).map((row) => (
|
||||||
|
<SettingChange key={row.field} row={row} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TripChip({ trip }: { trip: JourneyHeaderTrip }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const format = useCardFormat();
|
||||||
|
const days = trip.startDate ? format.date(trip.startDate) : null;
|
||||||
|
return (
|
||||||
|
<SoftPill
|
||||||
|
tone={TRIP_TONE[trip.change]}
|
||||||
|
className="max-w-full"
|
||||||
|
icon={<Luggage size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
|
||||||
|
>
|
||||||
|
{trip.change === 'link' && <span className="sr-only">{t('assistant.card.journeyHeader.tripLink')}</span>}
|
||||||
|
{trip.change === 'unlink' && <span className="sr-only">{t('assistant.card.journeyHeader.tripUnlink')}</span>}
|
||||||
|
<bdi className={`truncate ${trip.change === 'unlink' ? 'line-through' : ''}`}>{trip.title}</bdi>
|
||||||
|
{days && <span className="text-content-faint">{days}</span>}
|
||||||
|
</SoftPill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the change brings along or takes with it, in the journal's words. */
|
||||||
|
function HeaderNotes({ card }: { card: JourneyHeaderCard }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unlinking = card.trips.some((trip) => trip.change === 'unlink');
|
||||||
|
if (card.change === 'willDelete') {
|
||||||
|
return <p className={`${NOTE} text-danger`}>{t('assistant.card.journeyHeader.deletes')}</p>;
|
||||||
|
}
|
||||||
|
if (unlinking) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{card.entries !== null && card.entries > 0 && (
|
||||||
|
<p className={`${NOTE} text-content-muted`}>
|
||||||
|
{t('assistant.card.journeyHeader.entriesStay', { count: card.entries })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{card.suggestions !== null && card.suggestions > 0 && (
|
||||||
|
<p className={`${NOTE} text-content-muted`}>
|
||||||
|
{t('assistant.card.journeyHeader.suggestionsGo', { count: card.suggestions })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (card.suggestions === null || card.suggestions === 0) return null;
|
||||||
|
return (
|
||||||
|
<p className={`${NOTE} text-content-muted`}>
|
||||||
|
{t('assistant.card.journeyHeader.suggestionsCome', { count: card.suggestions })}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The journal's trips and what the change brings or takes, under the row. */
|
||||||
|
export function JourneyHeaderBody({ card, item }: { card: JourneyHeaderCard; item: ChangeItem }) {
|
||||||
|
if (GONE.has(item.state)) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{card.subtitle && card.change === 'new' && (
|
||||||
|
<p className="m-0 text-caption text-content-muted">
|
||||||
|
<bdi>{card.subtitle}</bdi>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{card.trips.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
{card.trips.map((trip, index) => (
|
||||||
|
<TripChip key={`${trip.id ?? 'x'}-${index}`} trip={trip} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<HeaderNotes card={card} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
|
||||||
|
*
|
||||||
|
* A change of a journal's photos in the chat: the entries the photos leave
|
||||||
|
* and reach, the photos as thumbnails, a caption old to new, what stays
|
||||||
|
* where, and the receipt that opens the entry.
|
||||||
|
*/
|
||||||
|
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||||
|
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||||
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
|
import { AssistantViewContext } from '../../assistantViewContext';
|
||||||
|
|
||||||
|
const MOVE: JourneyPhotosCard = {
|
||||||
|
kind: 'journeyPhotos',
|
||||||
|
journeyId: 3,
|
||||||
|
mode: 'move',
|
||||||
|
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
|
||||||
|
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
|
||||||
|
photos: [
|
||||||
|
{ photoId: 101, video: false, caption: 'Black sand' },
|
||||||
|
{ photoId: 102, video: true, caption: null },
|
||||||
|
],
|
||||||
|
more: 4,
|
||||||
|
kept: false,
|
||||||
|
caption: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
|
||||||
|
const preview: AssistantChangePreview = {
|
||||||
|
toolCallId: 'p1',
|
||||||
|
verbKey,
|
||||||
|
subject: 'Reynisfjara',
|
||||||
|
rows: [],
|
||||||
|
dayId: null,
|
||||||
|
placeIds: [],
|
||||||
|
undoable: false,
|
||||||
|
domain: 'journey',
|
||||||
|
card,
|
||||||
|
};
|
||||||
|
const receipt: AssistantApplied = {
|
||||||
|
toolCallId: 'p1',
|
||||||
|
entity: 'journey_entry',
|
||||||
|
ids: [2],
|
||||||
|
dayId: null,
|
||||||
|
undoUntil: null,
|
||||||
|
};
|
||||||
|
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||||
|
return render(
|
||||||
|
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
|
||||||
|
<AssistantChangeCard items={[item]} />
|
||||||
|
</AssistantViewContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a change of a journal photos', () => {
|
||||||
|
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
|
||||||
|
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
|
||||||
|
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vik')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
|
||||||
|
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
||||||
|
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
|
||||||
|
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
|
||||||
|
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
|
||||||
|
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
|
||||||
|
first.unmount();
|
||||||
|
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
|
||||||
|
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Entry cover')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
|
||||||
|
const caption = {
|
||||||
|
...MOVE,
|
||||||
|
mode: 'caption' as const,
|
||||||
|
from: null,
|
||||||
|
to: null,
|
||||||
|
more: 0,
|
||||||
|
photos: [{ photoId: 101, video: false, caption: 'Old' }],
|
||||||
|
};
|
||||||
|
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
|
||||||
|
expect(screen.getByText('Old')).toHaveClass('line-through');
|
||||||
|
expect(screen.getByText('New')).toBeInTheDocument();
|
||||||
|
first.unmount();
|
||||||
|
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
|
||||||
|
expect(screen.getByText('Removed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
|
||||||
|
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
|
||||||
|
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../../../i18n';
|
||||||
|
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||||
|
import { SoftPill } from '../../../Planner/planParts';
|
||||||
|
import type { ChangeItem } from '../../assistantParts';
|
||||||
|
import { FactChange } from '../parts/CardFacts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a change row of a journal's photos adds under the row: the entries
|
||||||
|
* the photos leave and reach, the photos themselves as small thumbnails (the
|
||||||
|
* first marked as the entry's cover in a new order), a caption old to new,
|
||||||
|
* and what the change leaves where. A change that failed, was discarded or
|
||||||
|
* ran out shows the row alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||||
|
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
|
||||||
|
|
||||||
|
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
|
||||||
|
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entries the photos leave and reach, the way between them marked. */
|
||||||
|
function Route({ card }: { card: JourneyPhotosCard }) {
|
||||||
|
if (!card.from && !card.to) return null;
|
||||||
|
return (
|
||||||
|
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
|
||||||
|
{card.from && <EntryName entry={card.from} />}
|
||||||
|
{card.from && card.to && (
|
||||||
|
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||||
|
)}
|
||||||
|
{card.to && <EntryName entry={card.to} />}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumbs({ card }: { card: JourneyPhotosCard }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (card.photos.length === 0 && card.more === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
{card.photos.map((photo, index) => (
|
||||||
|
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
|
||||||
|
<img
|
||||||
|
src={`/api/photos/${photo.photoId}/thumbnail`}
|
||||||
|
alt={photo.caption ?? ''}
|
||||||
|
loading="lazy"
|
||||||
|
className="size-full rounded-lg bg-surface-secondary object-cover"
|
||||||
|
/>
|
||||||
|
{photo.video && (
|
||||||
|
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
|
||||||
|
<span className="sr-only">{t('assistant.card.journey.video')}</span>
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
{card.mode === 'order' && index === 0 && (
|
||||||
|
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{card.more > 0 && (
|
||||||
|
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||||
|
{t('assistant.card.common.more', { count: card.more })}
|
||||||
|
</SoftPill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
|
||||||
|
function Notes({ card }: { card: JourneyPhotosCard }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
|
||||||
|
if (card.mode === 'unlink' || card.mode === 'move') {
|
||||||
|
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
|
||||||
|
}
|
||||||
|
if (card.mode === 'order' && card.photos.length > 0) {
|
||||||
|
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
|
||||||
|
}
|
||||||
|
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (GONE.has(item.state)) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Route card={card} />
|
||||||
|
<Thumbs card={card} />
|
||||||
|
{card.caption && (
|
||||||
|
<span className="mt-1.5 flex">
|
||||||
|
<FactChange
|
||||||
|
icon={MessageSquareText}
|
||||||
|
before={card.caption.before}
|
||||||
|
after={card.caption.after ?? t('assistant.card.common.removed')}
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Notes card={card} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { AssistantChangeRow } from '@trek/shared';
|
||||||
|
import {
|
||||||
|
CircleDot,
|
||||||
|
CloudSun,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Footprints,
|
||||||
|
Luggage,
|
||||||
|
MapPin,
|
||||||
|
Palette,
|
||||||
|
Route,
|
||||||
|
Smile,
|
||||||
|
Sparkles,
|
||||||
|
ThumbsUp,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pure side of a change of a journal itself: the glyph and name of each
|
||||||
|
* setting a change row names (under the tool's own field names), and how its
|
||||||
|
* value reads, a status by its name, a switch as on or off, a cleared status
|
||||||
|
* override as following the trip dates. The names are the journal's own,
|
||||||
|
* as its settings dialog writes them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FieldLook {
|
||||||
|
Icon: LucideIcon;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: Readonly<Record<string, FieldLook>> = {
|
||||||
|
status: { Icon: CircleDot, labelKey: 'journey.settings.status' },
|
||||||
|
status_override: { Icon: Eye, labelKey: 'assistant.card.journeyHeader.field.statusOverride' },
|
||||||
|
show_verdict: { Icon: ThumbsUp, labelKey: 'journey.settings.showVerdict' },
|
||||||
|
show_mood: { Icon: Smile, labelKey: 'journey.settings.showMood' },
|
||||||
|
show_weather: { Icon: CloudSun, labelKey: 'journey.settings.showWeather' },
|
||||||
|
photo_location: { Icon: MapPin, labelKey: 'journey.settings.photoLocation' },
|
||||||
|
show_trip_tracks: { Icon: Route, labelKey: 'journey.settings.showTripTracks' },
|
||||||
|
auto_prepare_recordings: { Icon: Footprints, labelKey: 'journey.settings.autoPrepare' },
|
||||||
|
cover_gradient: { Icon: Palette, labelKey: 'assistant.card.journeyHeader.field.coverGradient' },
|
||||||
|
trip: { Icon: Luggage, labelKey: 'assistant.card.journeyHeader.field.trip' },
|
||||||
|
suggestions: { Icon: Sparkles, labelKey: 'assistant.card.journeyHeader.field.suggestions' },
|
||||||
|
hide_skeletons: { Icon: EyeOff, labelKey: 'journey.skeletons.hide' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUSES: Readonly<Record<string, string>> = {
|
||||||
|
draft: 'journey.status.draft',
|
||||||
|
active: 'journey.status.active',
|
||||||
|
completed: 'journey.status.completed',
|
||||||
|
archived: 'journey.status.archived',
|
||||||
|
live: 'journey.frontpage.live',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLAG_ON = '✓';
|
||||||
|
const FLAG_OFF = '✗';
|
||||||
|
|
||||||
|
function own<T>(table: Readonly<Record<string, T>>, key: string | null | undefined): T | null {
|
||||||
|
if (!key || !Object.prototype.hasOwnProperty.call(table, key)) return null;
|
||||||
|
return table[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The look of a setting a row names; null for a field the card has no name for. */
|
||||||
|
export function headerFieldOf(field: string | undefined): FieldLook | null {
|
||||||
|
return own(FIELDS, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key a value of a setting reads by, or null for one that reads as it is
|
||||||
|
* (a name, a count). A cleared status override follows the trip dates again.
|
||||||
|
*/
|
||||||
|
export function headerValueKey(field: string | undefined, value: string | null): string | null {
|
||||||
|
if (value === FLAG_ON) return 'settings.on';
|
||||||
|
if (value === FLAG_OFF) return 'settings.off';
|
||||||
|
if (field === 'status_override' && !value) return 'journey.settings.statusAuto';
|
||||||
|
if (field === 'status' || field === 'status_override') return own(STATUSES, value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The setting rows of a change, the trips aside: the card draws those as its own chips. */
|
||||||
|
export function settingRows(rows: readonly AssistantChangeRow[]): AssistantChangeRow[] {
|
||||||
|
return rows.filter((row) => row.labelKey === 'field' && row.field !== 'trip' && headerFieldOf(row.field) !== null);
|
||||||
|
}
|
||||||
@@ -1,4 +1,22 @@
|
|||||||
import { BookOpen, BookOpenText, BookPlus, BookX } from 'lucide-react';
|
import {
|
||||||
|
ArrowDownUp,
|
||||||
|
ArrowRightLeft,
|
||||||
|
BookCheck,
|
||||||
|
BookOpen,
|
||||||
|
BookOpenText,
|
||||||
|
BookPlus,
|
||||||
|
BookX,
|
||||||
|
Eye,
|
||||||
|
ImageMinus,
|
||||||
|
ImagePlus,
|
||||||
|
Images,
|
||||||
|
Link,
|
||||||
|
MessageSquareText,
|
||||||
|
RotateCcw,
|
||||||
|
Settings2,
|
||||||
|
Type,
|
||||||
|
Unlink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { PackLook } from '../cardTypes';
|
import type { PackLook } from '../cardTypes';
|
||||||
import { journalTarget } from './journeyCardModel';
|
import { journalTarget } from './journeyCardModel';
|
||||||
@@ -17,6 +35,34 @@ export const JOURNEY_LOOK = {
|
|||||||
addJournalEntry: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.addJournalEntry' },
|
addJournalEntry: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.addJournalEntry' },
|
||||||
updateJournalEntry: { Icon: BookOpenText, tone: 'info', labelKey: 'assistant.change.verb.updateJournalEntry' },
|
updateJournalEntry: { Icon: BookOpenText, tone: 'info', labelKey: 'assistant.change.verb.updateJournalEntry' },
|
||||||
removeJournalEntry: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.removeJournalEntry' },
|
removeJournalEntry: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.removeJournalEntry' },
|
||||||
|
reorderJournalEntries: {
|
||||||
|
Icon: ArrowDownUp,
|
||||||
|
tone: 'info',
|
||||||
|
labelKey: 'assistant.change.verb.reorderJournalEntries',
|
||||||
|
},
|
||||||
|
createJournal: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.createJournal' },
|
||||||
|
renameJournal: { Icon: Type, tone: 'info', labelKey: 'assistant.change.verb.renameJournal' },
|
||||||
|
setJournalStatus: { Icon: BookCheck, tone: 'info', labelKey: 'assistant.change.verb.setJournalStatus' },
|
||||||
|
updateJournalSettings: { Icon: Settings2, tone: 'info', labelKey: 'assistant.change.verb.updateJournalSettings' },
|
||||||
|
deleteJournal: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.deleteJournal' },
|
||||||
|
linkJournalTrip: { Icon: Link, tone: 'success', labelKey: 'assistant.change.verb.linkJournalTrip' },
|
||||||
|
unlinkJournalTrip: { Icon: Unlink, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalTrip' },
|
||||||
|
restoreJournalSuggestions: {
|
||||||
|
Icon: RotateCcw,
|
||||||
|
tone: 'info',
|
||||||
|
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
|
||||||
|
},
|
||||||
|
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
|
||||||
|
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
|
||||||
|
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
|
||||||
|
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
|
||||||
|
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
|
||||||
|
captionJournalPhoto: {
|
||||||
|
Icon: MessageSquareText,
|
||||||
|
tone: 'info',
|
||||||
|
labelKey: 'assistant.change.verb.captionJournalPhoto',
|
||||||
|
},
|
||||||
|
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
create_journey: { labelKey: 'assistant.tool.create_journey' },
|
create_journey: { labelKey: 'assistant.tool.create_journey' },
|
||||||
@@ -35,10 +81,21 @@ export const JOURNEY_LOOK = {
|
|||||||
journey_entry: {
|
journey_entry: {
|
||||||
Icon: BookOpen,
|
Icon: BookOpen,
|
||||||
openLabelKey: 'assistant.card.journey.open',
|
openLabelKey: 'assistant.card.journey.open',
|
||||||
open: (applied, card) =>
|
open: (applied, card) => {
|
||||||
card?.kind === 'journeyEntries'
|
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
|
||||||
|
return card?.kind === 'journeyEntries'
|
||||||
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
|
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
|
||||||
: null,
|
: null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
journey: {
|
||||||
|
Icon: BookOpen,
|
||||||
|
openLabelKey: 'assistant.card.journeyHeader.open',
|
||||||
|
open: (applied, card) => {
|
||||||
|
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
|
||||||
|
const journeyId = applied.ids[0] ?? fromCard;
|
||||||
|
return journeyId ? journalTarget(journeyId, null, false) : null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cards: {
|
cards: {
|
||||||
@@ -59,5 +116,11 @@ export const JOURNEY_LOOK = {
|
|||||||
return readIds.length > 0 && readIds.every((id) => id !== null && touched.has(id));
|
return readIds.length > 0 && readIds.every((id) => id !== null && touched.has(id));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
journeyHeader: {
|
||||||
|
entity: () => 'journey',
|
||||||
},
|
},
|
||||||
} satisfies PackLook<'journeyEntries'>;
|
journeyPhotos: {
|
||||||
|
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { CardView } from '../cardTypes';
|
import type { CardView } from '../cardTypes';
|
||||||
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
|
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
|
||||||
import JourneyEntriesBlock from './JourneyEntriesBlock';
|
import JourneyEntriesBlock from './JourneyEntriesBlock';
|
||||||
|
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
|
||||||
|
import { JourneyPhotosBody } from './JourneyPhotosSlots';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the kinds of the journeys draw in the chat: the entries a read found
|
* How the kinds of the journeys draw in the chat: the entries a read found
|
||||||
* as a block of their own, and a change row of an entry with its photo beside
|
* as a block of their own, and a change row of an entry with its photo beside
|
||||||
* the subject, its day, time and place and whatever an update changes on the
|
* the subject, its day, time and place and whatever an update changes on the
|
||||||
* faint line, and the start of its text under it. This file only maps each
|
* faint line, and the start of its text under it; a change of the journal
|
||||||
|
* itself with its settings on the faint line and its trips under it. This file only maps each
|
||||||
* kind to its view: the components live in sibling files, since a file that
|
* kind to its view: the components live in sibling files, since a file that
|
||||||
* exports anything besides components breaks fast refresh, and lint:warnings holds
|
* exports anything besides components breaks fast refresh, and lint:warnings holds
|
||||||
* react-refresh/only-export-components at zero for a new file.
|
* react-refresh/only-export-components at zero for a new file.
|
||||||
@@ -18,4 +21,11 @@ export const JOURNEY_VIEWS = {
|
|||||||
Facts: JourneyEntryFacts,
|
Facts: JourneyEntryFacts,
|
||||||
Body: JourneyEntryBody,
|
Body: JourneyEntryBody,
|
||||||
},
|
},
|
||||||
} satisfies { [K in 'journeyEntries']: CardView<K> };
|
journeyHeader: {
|
||||||
|
Facts: JourneyHeaderFacts,
|
||||||
|
Body: JourneyHeaderBody,
|
||||||
|
},
|
||||||
|
journeyPhotos: {
|
||||||
|
Body: JourneyPhotosBody,
|
||||||
|
},
|
||||||
|
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };
|
||||||
|
|||||||