Compare commits

..

103 Commits

Author SHA1 Message Date
Maurice a9fb0fc475 feat(demo): the welcome dialog credits every photo of the demo content
A Photo credits link in the dialog's footer opens a second view with every photo: its title linking to its page on Wikimedia Commons, its author and its license linking to the deed. The list is its own chunk, loaded when a visitor opens it. The countdown test reads the clock the interval moved.
2026-10-24 23:22:15 +00:00
Maurice 5596355141 refactor(demo): the account tab and its phone screen share one demo lock
Both views had grown the same lock line in front of the same settings destructuring, which made the two a copy.
2026-10-24 23:22:15 +00:00
Maurice 9c83386655 test(demo): the OAuth demo lock cases take their controller from OauthModule 2026-10-24 23:22:15 +00:00
Maurice 98961dadd5 fix(demo): the addon policy names the legacy memories row, which stays off
The trip Photos addon of old releases is still seeded, switched off, and nothing reads it any more. The policy test now fails on no row, and its checks run without awaiting in a loop.
2026-10-24 23:22:15 +00:00
Maurice 7ada99acfc fix(demo): the demo's addon set and the demo account's settings are written without awaiting in a loop
One UPDATE per shelf and state and one upsert for the switches, inside the same transaction, and the demo account's settings in one upsert. The two new log lines go through the logger.
2026-10-24 23:22:15 +00:00
Maurice 2ac333f842 fix(demo): skip the example trips when the demo account owns its own
The seed put the example trips in whenever the admin owned none, so a demo
whose content belongs to the demo account got them back on every reopen,
the hourly reset included, and the next baseline saved them along.
2026-10-24 23:22:15 +00:00
Maurice 270a13ff60 feat(demo): the AI assistant, AI parsing and the MCP server stay off in the demo
They spend the instance's AI credit or hand tokens to a shared account, so
they belong to a self-hosted install, not to the public demo.
2026-10-24 23:22:15 +00:00
Maurice d800e64f93 feat(demo): lock the demo account's settings under a striped demo overlay
The demo account can look at its Account tab, API keys, MCP configuration,
own AI connection and language, but not change them, and the server already
refuses each of those writes. DemoLock renders such a section dimmed and out
of reach under diagonal stripes in theme tokens, with a small Demo mode badge
and one line saying why; useDemoAccountLock decides, on the desktop settings
and the phone screens alike (a new pair in feature-pairs.json).

Locked: the whole Account tab (profile, password, 2FA, passkeys, deletion),
API keys, the MCP card with its tokens and connected apps, connecting an own
AI model and the language picker, which shows English. The admin of a demo
instance sees everything unlocked, 2FA included, so the old instance wide
"Not available in demo mode" line under 2FA and its key are gone from all
locales.
2026-10-24 23:22:15 +00:00
Maurice 390135f3bc feat(demo): a new welcome dialog for the demo, in the 5.0 dialog style
The old popup was a narrow white card with its own copy of the texts in
eight languages, hex colours and a minute counter on the browser clock. The
new DemoWelcome is a wide dialog on DialogShell with theme tokens only: what
TREK is in one line, the hourly reset with a live countdown in the server's
time zone, the 5.0 highlights to try right away (trip planner with bookings
and costs, Tours with 3D terrain, TREK Studio photo books, live
collaboration, Journey, Atlas, Vacay, Collections, offline use), what the
demo leaves out and what the full version adds, and the link to the source.

Its strings are real i18n keys in a new demo domain file, translated into
all 27 locales. Both dashboards open it instead of the old DemoBanner, which
leaves the theme and format baselines with its file and the test non-null
assertions it held.
2026-10-24 23:22:15 +00:00
Maurice cad7d54e83 feat(shared): the demo account's addresses live in one place for both sides
The client is about to lock settings sections for the shared demo account,
and it has to ask the same question the server does. DEMO_ACCOUNT_EMAILS and
isDemoAccountEmail now sit in @trek/shared, and the server's DEMO_EMAILS is
built from them instead of spelling the two addresses a second time.
2026-10-24 23:22:15 +00:00
Maurice 2df677d065 refactor(demo): the addon states type names its shelves inline
The shelf shape was an exported interface nothing outside the repository
imports, which knip counts as an unused export.
2026-10-24 23:22:15 +00:00
Maurice 03493d4f79 feat(demo): a fixed addon set on every boot and after every hourly reset
A demo instance now runs with one explicit addon policy (demo-addon-policy.ts)
instead of whatever its admin last switched: every trip, global and
integration addon on, and off only what reaches a server of the visitor's
own, the five document sync providers, wanderer under Tours, the Immich and
Synology Photos providers, AirTrail and Dawarich. The switches under the
addons (Tours maps, piste routing, bag tracking, the collab features) are on.

The demo seed applies it, together with the demo account's English
language, on every boot and after every reopen, and the hourly reset applies
it once more after the baseline swap, in one transaction each. A test pins
that every seeded addon and provider is named in exactly one list, so a new
addon has to be classified before it ships.
2026-10-24 23:22:15 +00:00
Maurice eae74c5605 feat(demo): the shared demo account keeps its keys, account and language
Every visitor of a demo instance signs in as the same account, so one
visitor's API key, MCP token, OAuth client, passkey or new email address
became everybody's. These writes now answer 403 for the demo account through
refuseForDemoAccount in demo-write.ts: creating API keys, creating and
deleting MCP tokens, registering, rotating and deleting OAuth clients,
approving an app and ending a connected app, changing the name, address or
picture, and enrolling, renaming or deleting passkeys. Password, 2FA,
account deletion, sessions and the own AI connection were refused already.

The demo account's language is fixed to English: a single settings write of
the language answers unchanged, a bulk save drops it and keeps the rest. The
MCP settings tool is held back for the demo account by the tool gate, as
every write tool is. The admin of a demo instance and every real instance
are untouched.
2026-10-24 23:22:15 +00:00
Maurice ec486214f1 docs(wiki): the compose page names the storage seed file without a path the repo does not hold 2026-10-24 22:42:18 +00:00
Maurice d09a09ad52 Link the new wiki pages from the related sections of the pages around them 2026-10-24 22:42:18 +00:00
Maurice 8b41561db9 Reorder the wiki sidebar and link the new pages from Home
The sidebar now follows the way a user meets TREK: account, everyday use and
settings before planning, Tours beside Road trip and wanderer, Studio beside
Journey, and an AI and MCP section led by the assistant pages. Admin pages
follow the real tab order, with Backups and the Audit log at its end.
2026-10-24 22:42:18 +00:00
Maurice 73744203fb Add wiki pages for trip settings, the notifications inbox, the phone layout and wanderer
Each of these is a screen or a connection users meet in 5.0 that no page
explained: the trip's People, Sharing and Journal areas, the inbox page and
bell, what the phone layout offers and leaves to the desktop, and connecting
and watching a wanderer instance.
2026-10-24 22:42:18 +00:00
Maurice bc385f27dd Update the admin notifications, MCP, releases, audit and backup wiki pages
Add the paddle region alert, the to-do reminders, the new audit keys for Tours, paddle water, wanderer and AI, the release history paging and the backup hour hint. Dashes are replaced with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice 866b9e94af Update the admin categories, packing templates, plugins and storage wiki pages
Packing template deletes ask for confirmation, plugin versions can be changed and updates paused, and the toolbar buttons are icon buttons. Dashes are replaced with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice ed7c79bcb7 Rewrite the admin addons page for the current layout and the AI tile
The addons are searchable and filterable in cards per type, Tours is on by default and carries the wanderer, hiking map, cycling map and piste router switches, MCP, AI Parsing and the AI Assistant share one AI tile with a setup dialog, and the paddle water card loads regions automatically or on request. The overview lists the AI Assistant addon.
2026-10-24 22:42:18 +00:00
Maurice 1e4b618878 Update the admin users, defaults, settings and permissions wiki pages
Add the Reset 2FA action of the edit user dialog, the invite delete confirmation, the Link a journal permission and the phone admin sections. Replace dashes with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice 982861264e Add wiki pages for the AI assistant, AI connections and TREK Studio
The assistant, the shared AI connection and the Studio editor had no page of
their own; these describe them from the current screens and limits.
2026-10-24 22:42:18 +00:00
Maurice 6434fe7a3c Describe the update dialog, demo baseline card, phone admin and paddle panel in the admin overview 2026-10-24 22:42:18 +00:00
Maurice 70e07f1965 Update the demo, hardening, network and proxy wiki pages
Document the demo welcome popup, name wanderer and the AI assistant hosts in the SSRF guard lists, and list the paths a restricted reverse proxy must forward for MCP sign-in.
2026-10-24 22:42:18 +00:00
Maurice 87041c8842 Update the login, reset, 2FA, passkey and SSO wiki pages for the current screens
Describe the world map login panel, the first-run admin form, the plain HTTP cookie warning, the SSO error messages, the reset page states and the Reset 2FA action in the admin user dialog. Disabling 2FA accepts a backup code.
2026-10-24 22:42:18 +00:00
Maurice edb113f97f Update the settings wiki pages for the 5.0 tabs, cards and integrations
The Account tab is a stack of separate cards, the Integrations tab gained the wanderer connection and the AI connection, and the notification forms have test buttons. Dashes in the text are replaced with plain punctuation.
2026-10-24 22:42:18 +00:00
Maurice fcd5f5dbb6 Correct car and parking hand-over events, stop lengths and phone entry points on the calendar feeds page 2026-10-24 22:42:18 +00:00
Maurice 6849742d67 Update the PDF export page for Markdown in day pages and the current Studio layouts, themes and typefaces 2026-10-24 22:42:18 +00:00
Maurice 022e088da4 docs(wiki): point Tours and Dawarich at the Help Center and describe what the AI assistant does for tours and road trips 2026-10-24 22:42:18 +00:00
Maurice 2471fc5aa7 Document the Synology login fix, the connection test verdict and offline limits on the document sync page 2026-10-24 22:42:18 +00:00
Maurice fad301ef5e Replace dashes in the lines added to the install, update and contributing pages 2026-10-24 22:42:18 +00:00
Maurice 1c83bf78fa Update the Proxmox and Unraid install pages for first-start credentials, the pre-migrate copy and extra variables 2026-10-24 22:42:18 +00:00
Maurice a6cd4ffdff docs(wiki): add the winter layer of the ski piste router to the TREK Places API page, including what switching the index off does to ski tours 2026-10-24 22:42:18 +00:00
Maurice 988d27c39d Explain the stand-alone files page, offline limits and assistant file keeping on the files page 2026-10-24 22:42:18 +00:00
Maurice 62b954cffc Bring the developer setup and contributing pages up to the current scripts, CI gates and pull request rules 2026-10-24 22:42:18 +00:00
Maurice a2fed6afb8 Replace dashes in the lines touched by the wiki refresh 2026-10-24 22:42:18 +00:00
Maurice 97510e007e Add the phone layout, delete behaviour and the assistant to the to-do page 2026-10-24 22:42:18 +00:00
Maurice 36f81934aa Describe list reordering and the phone delete question in Packing Lists, and correct the template screenshot caption 2026-10-24 22:42:18 +00:00
Maurice 303bce431d docs(wiki): mention the SSO and passkey sign-in on the login page in the quick start 2026-10-24 22:42:18 +00:00
Maurice 73811e0bb7 Troubleshooting: Web Push, stale app versions, refused starts, AI parsing limits, place search fallback and offline sync failures 2026-10-24 22:42:18 +00:00
Maurice 0ca9f7241a Add PLACES_API_BASE, TREK_DB_FILE and an AI Assistant note to the variable reference and list the assistant connections in the key rotation page 2026-10-24 22:42:18 +00:00
Maurice bab4c7edcd docs(wiki): point Tags and Categories at the exact places to pick, create and filter a category 2026-10-24 22:42:18 +00:00
Maurice 166b0d8ab3 Update the Costs and Currencies pages for delete confirmation, offline receipts, decimals and the trip currency location 2026-10-24 22:42:18 +00:00
Maurice a5dbf6934c docs(wiki): add the recorded route, the Tours map layers and dragging markers to Map Features, and the routing engines to Map Settings and Route Optimization 2026-10-24 22:42:18 +00:00
Maurice d2bd6e7556 FAQ: answers for the AI assistant, Tours, Studio, push notifications, Dawarich, the help center and the health endpoint 2026-10-24 22:42:18 +00:00
Maurice 676d086ff2 Rewrite the AI booking import page around the shared AI connection and the Set up AI dialog 2026-10-24 22:42:18 +00:00
Maurice d8793f3d0b docs(wiki): describe choosing a place's picture in the editor and the photo gallery of the inspector 2026-10-24 22:42:18 +00:00
Maurice fcf4c6e736 Update the Updating page to 5.x examples and describe what 5.0 changes for an update 2026-10-24 22:42:18 +00:00
Maurice fa40dfba2c Help-Center: looping clips, the ? on tablets and the newer screens 2026-10-24 22:42:18 +00:00
Maurice 2a17227337 Rewrite the Portainer install page for the current compose file, 5.x tags, health status and the update switch 2026-10-24 22:42:17 +00:00
Maurice a5757dd59f Offline-Mode-and-PWA: desktop install, the book-fonts cache and the status pill 2026-10-24 22:42:17 +00:00
Maurice 2e10263155 docs(wiki): add tours to the day timeline of the day plan page 2026-10-24 22:42:17 +00:00
Maurice 7a7c66e801 Name the Vacay entitlement tiles and the poll countdown as the app spells them 2026-10-24 22:42:17 +00:00
Maurice a55728fe46 Collections: mention the List and Map switch on narrow windows and the assistant 2026-10-24 22:42:17 +00:00
Maurice 8bd5e428b4 docs(wiki): say only what the notification list does on the dashboard page, and drop dashes from the lines changed 2026-10-24 22:42:17 +00:00
Maurice 504b58ecf6 Correct the Helm env key note, add the Recreate update strategy and the pre-migrate copy 2026-10-24 22:42:17 +00:00
Maurice 0f05b0342c Photo-Providers: photos attach to journeys and Studio, not to trips 2026-10-24 22:42:17 +00:00
Maurice 7eea0e7669 Drop the Studio beta label and describe the Studio assistant and the trip's journal link in Journey-Journal 2026-10-24 22:42:17 +00:00
Maurice 0f88a74976 Bring the Docker and Docker Compose install pages to 5.x tags, first start behaviour, healthcheck, log rotation and optional variables 2026-10-24 22:42:17 +00:00
Maurice 9ffd5e5ffb docs(wiki): describe the cover photo search, the new start date step and offline edits of a trip, and the dashboard's hero badge, stat tiles and Idea cards 2026-10-24 22:42:17 +00:00
Maurice 00a326029b Add the wanderer hint and paddle region events and the full notifications page to Notifications 2026-10-24 22:42:17 +00:00
Maurice a47b35b965 Name the Upload plugin button, document dev link in the plugin panel and update the version example in the public API page 2026-10-24 22:42:17 +00:00
Maurice fcfcf2ff17 Document the end time hint, offline saving and the assistant on the bookings, transport and stay pages 2026-10-24 22:42:17 +00:00
Maurice 016567dd9a Document the trip journal link tools, the city walk check-off tool, metadata-document clients and the AI Assistant on the MCP pages 2026-10-24 22:42:17 +00:00
Maurice b64dd700a8 docs(wiki): name Tours, the AI Assistant, Trip settings and the notifications inbox on Home, and describe the planner's top bar and its Tours and Journal tabs 2026-10-24 22:42:17 +00:00
Maurice b0b6f5872f Wiki: name Tours, the AI Assistant, Trip settings and the notifications inbox on Home, and describe the planner's top bar and its Tours and Journal tabs 2026-10-24 22:42:17 +00:00
Maurice e119a2c936 chore(maps): the map test's size baseline drops to the 2919 lines it holds 2026-10-24 22:41:56 +00:00
Maurice 45ae8b5af2 fix(maps): narrow the PDF map container and assert the 3D toast by its key
The offscreen container is built inside buildGlMap's closure, where the
let lost its narrowing; a const of its own keeps it an HTMLDivElement.
The stage tests hand the stage a t that returns the key, so the toast
reads map.webgl2.terrain there. One floating promise fewer lowers the
eslint baseline to 164.
2026-10-24 22:41:56 +00:00
Maurice e28bd2fc99 fix(maps): draw with Leaflet wherever WebGL2 is missing
MapLibre 6 and Mapbox GL 3 both throw out of their Map constructor without
WebGL2. Every place that builds a GL map now asks one helper first,
components/Map/glSupport.ts, and a browser without WebGL2 gets the Leaflet
map with the user's tiles instead of a map that fails to start:

- the trip map (MapViewAuto) and the journey maps (JourneyMapAuto) pick
  their renderer through useGlRenderer, which folds in the Mapbox token
  rule they each had;
- the settings preview, desktop and phone, shows the Leaflet preview
  through useMapSettingsForm and says why under it;
- the tour planner keeps the 2D map when 3D is asked for and says that
  3D needs WebGL2;
- the PDF route map and the Leaflet vector basemap already fell back and
  now report a GPU failure too.

Behind the probe, buildGlMap wraps each constructor (MapViewGL,
JourneyMapGL, the settings preview, the terrain map, the PDF map). A GPU
that claims WebGL2 and still refuses the context is reported through
utils/webgl, which turns the answer to no and tells every mounted map,
so they switch to Leaflet without a reload; any other error is still
thrown. Two new strings, map.webgl2.fallback and map.webgl2.terrain, in
all 27 locales.
2026-10-24 22:41:56 +00:00
Maurice 1696162d22 fix(deps): maplibre-gl 6.15.0 past the attribution sanitizer advisory
GHSA-jrc7-96c5-q579 covers every maplibre-gl up to 6.4.0 and has no 5.x
release, so npm audit kept flagging the patched 5.24.0. Moving to 6.15.0
retires the local backport: patch-maplibre.mjs and its postinstall,
predev and prebuild hooks go, along with the Dockerfile copy and its
format-baseline entry. 6.x sanitizes attributions against an allow list.

What the major changes for TREK:
- ESM only, no default export: the engine module imports the namespace
  and hands that out as its default, so every caller stays as it was.
- The worker is no longer inlined, and inside a bundle MapLibre cannot
  find it next to its own module. The engine sets it from Vite's
  ?worker&url build before any map, the Leaflet bridge's included.
- WebGL2 only: the probe asks for webgl2 alone, so a WebGL1-only browser
  gets the raster basemap instead of a GPUInitializationError.
- 6.9 shapes right-to-left text itself and deprecated setRTLTextPlugin;
  the old plugin would replace that and could only run through eval in
  the module worker, so MapLibre no longer registers it. Mapbox does.

The lockfile moves maplibre-gl and its own dependencies only; the
Leaflet bridge already allows ^6.0.0 as its peer.
2026-10-24 22:41:56 +00:00
Maurice f375a2f3cc fix(collab): a quoted message wraps to two lines instead of pushing its bubble out of the chat
The quote inside a reply sat on one line with an ellipsis, and that unbroken
line set the bubble's minimum width, so a long quote pushed the bubble past
the edge of the chat and cut its words off. It wraps now and is cut after two
lines, on desktop and on the phone.
2026-10-25 00:21:06 +02:00
Maurice 53bd21a7d7 test(assistant): a changed journal entry can be undone
update_journey_entry answers the fields it replaced, so its card is
undoable now, as JRN-015 already checks for the undo itself.
2026-10-24 22:54:46 +02:00
Maurice 67d8f22622 test(assistant): the catalog check lists the Dawarich tools too
A stay taken into a journal now has a card of its own, so ASTTOOL-001
looks for its tool; Dawarich's tools are listed only with its addon on,
which the test now turns on beside the others.
2026-10-24 22:54:46 +02:00
Maurice 8433251c86 fix(assistant): the Studio's empty chat speaks of the book again
The welcome chose the journal's words whenever the Studio had not
registered as the chat's host, which also hit the Studio's own panel
before (or without) that registration. A journey's chat now speaks of the
journal only where the window or the phone's chat screen say they show it
for its journal; everywhere else, the Studio included, it stays the book's.

AssistantWindow had gone past the complexity limit choosing between the
planner's trip, the journal on screen, a picked chat and the home chat;
that choice now lives in windowChat.
2026-10-24 22:54:46 +02:00
Maurice 13449c3338 fix(assistant): no awaits in loops and no value only used as a type in the journal code
The journal header's tool list was a value read only for its type, so it is
a union now. A journey's session reads the entries a call names side by side,
the photo announcements go out together, and a photo move links its photos
one after another through a promise chain, so each still takes the next
place among the target's photos.
2026-10-24 22:54:46 +02:00
Maurice 35c3ab4bd7 fix(assistant): a journey's session no longer attaches delete_journey
isHeldInJourney took every tool of the journey's controllers from the Studio
side, delete_journey among them. The Studio never offered it (its writes are
the book's), so leaving it unattached changes nothing the model sees and
keeps the denial in one place.
2026-10-24 22:54:46 +02:00
Maurice 7356de5124 fix(journey): give the patched column shapes an index signature presenceSet accepts
presenceSet takes a Record<string, unknown>; an interface has no implicit
index signature, so the two column shapes are type aliases instead.
2026-10-24 22:54:46 +02:00
Maurice 8a2cb10f7b fix(assistant): tighten the journal cards' types and keep their helpers private
A Dawarich stay's row read fields one side of a union did not have, the
photo cards narrowed an entry's day through an optional chain, and the
settings service cast a journey row straight to a record. Helpers only
their own file reads are no longer exported, and the photo tests seed
their rows with insertRow like the other journey suites.
2026-10-24 22:54:45 +02:00
Maurice 0050072b76 feat(assistant): photo changes of a journal get their own card and ways back
The four new photo link tools join the journal's tools: the journey chat
offers them to editors, the trip chat loads them with the journey group and
the home chat keeps none. A journeyPhotos card shows the photos as
thumbnails with the entries they leave and reach, the first one marked as
the cover in a new order, and a caption old to new; a library add shows the
photos once they landed. A linked photo comes off again, an unlinked one
goes back, moved photos go back and a caption is written back, each while
nothing changed since. A new order of an entry's photos and a library add
have no way back yet. The journal prompt names the photo tools.
2026-10-24 22:54:45 +02:00
Maurice da43a81d26 docs(wiki): the journey MCP tools that answer what they replaced, and the photo link tools
update_journey takes show_trip_tracks and cover_gradient and answers
previous, update_journey_entry answers previous, reorder_journey_entries
answers previous_order, and the four photo link tools are listed.
2026-10-24 22:54:45 +02:00
Maurice a955328070 feat(journey): photo links tell every tab, move between entries, and gain MCP tools
Linking a gallery photo to an entry and taking it off told nobody, so other
tabs, and the journey page after an assistant change, stayed as they were.
Both now go through a photo links service that broadcasts
journey:entry:updated after the write, with the asking socket left out on
REST; a caption change through PATCH photos/:id broadcasts journey:updated
the same way. The service also moves photos from one entry to another of
the same journey in one transaction, as POST
/api/journeys/entries/:entryId/photos/move, with a strict contract. The MCP
tools link_journey_photo, unlink_journey_photo, move_journey_photos and
set_journey_photo_caption call the same methods; the caption tool answers
the caption it replaced.
2026-10-24 22:54:45 +02:00
Maurice 53c05ab33b feat(assistant): entry cards show tags, verdict and order, and changes can be taken back
The update card silently left out the tags and the pros and cons it was
about to write, so the user approved a change the card did not show. An
entry now shows its first tags and how its verdict leans, and an update
names the tags, each side of the verdict and the coordinates it changes.
A new order of the entries is drawn as the entries that move, in their new
places. A changed entry is written back from what the tool answered it
replaced, and a reorder is taken back to the order before, each while
nothing changed since. A Dawarich stay taken in as an entry gets the
entry card; it has no way back, since deleting the entry would leave the
stay marked as taken in.
2026-10-24 22:54:45 +02:00
Maurice 5160bd23d5 feat(journey): entry updates and reorders answer what they replaced
update_journey_entry now answers previous, the entry's earlier values of
exactly the fields it names (a suggestion written up goes back to being
one), and reorder_journey_entries answers previous_order, the ids in the
order they stood in before. Both come from the service the REST routes
use, which now builds an entry's patch in one pure helper; the routes'
bodies are unchanged. The tools' inline zod moves into strict schemas in
@trek/shared.
2026-10-24 22:54:44 +02:00
Maurice 48ff1b3a98 feat(assistant): curated cards and undo for the changes of a journal itself
Starting a journal, changing its settings, linking or unlinking a trip,
bringing back its suggestions, the user's own view of it and deleting it
fell back to the generic card, which names ids and offers no way back.
They now share a journeyHeader card: the journal with its trips, the one
the change links or unlinks marked, what a new journal brings in and what
a deletion or an unlink takes, and each setting old to new in the
journal's own words. A new journal is deleted again while nothing was
written, added or laid out in it, changed settings are written back from
what the tool answered, a linked trip is unlinked by its owner while none
of its suggestions was written up, and the own switch flips back.
2026-10-24 22:54:44 +02:00
Maurice cf35dc7afd feat(journey): update_journey takes every setting REST does and answers what it replaced
The MCP tool lacked show_trip_tracks and cover_gradient, which PATCH
/api/journeys/:id has always taken. Both now read the input through one
helper, and the tool goes through a small settings service that writes the
same patch in a transaction with the stored values it replaces, answered
as previous, so a change can be written back. The subtitle and the cover
gradient take null to clear them. The tool's inline zod moves into strict
schemas in @trek/shared; the REST route and its body are unchanged.
2026-10-24 22:54:43 +02:00
Maurice 4b3340546f feat(assistant): the journey page opens the journal's chat on desktop and phone
On /journey/:id the assistant window and the phone's assistant screen now
open the journey's conversation, the one TREK Studio holds too, instead of
the home chat; the Studio route keeps its own column. A turn sent from a
chat the Studio does not host names the journal page, a hosted one the
Studio, so the server offers the right tools. A journal card from the home
chat opens that conversation in place on the desktop and the journal page
on the phone, and the empty chat asks about the journal rather than the
book.
2026-10-24 22:54:43 +02:00
Maurice 1d9648267b feat(assistant): the home chat starts a journal and hands a journal's questions to its page
Asked on /journey to create "Japan 2026", the home chat had nothing to
offer: it keeps no write that names a trip or a journey. It now keeps
create_journey, whose trips the tool checks one by one, and delete_journey,
which the journey's own chat leaves out because the conversation goes
with it. For anything inside a journal it offers open_journey_chat, the
twin of open_trip_chat: the journal is read from the user's own journals
and shown as a card whose button goes on in the journal's chat with the
question waiting there. The trip and journal cards share their question
and button.
2026-10-24 22:54:43 +02:00
Maurice c0ec8e567b feat(assistant): the journey page gets a journal chat that offers its tools by role
Asked on a journey page to write an entry, the assistant said it could not:
the only journey chat was the Studio's, which keeps the journey's writes
out. A journey session now holds what either page offers and shows the
model only the asking page's tools. The journal page offers a viewer the
reads, an editor the entry, photo, recorded day and day import writes, and
the owner also the journey's own settings and trips; delete_journey stays
out of the journey's own chat. A few reads that name no journey come along
(place search, the photo libraries, Dawarich stays), the Dawarich journal
entry is pinned through its journalId, and an entry an argument names must
belong to the conversation's journey before the tool runs. The turn reads a
journal card and instructions of its own; the Studio keeps exactly what it
offered before.
2026-10-24 22:54:42 +02:00
Maurice 2508fd8624 feat(assistant): a journey's chat request names the page it is asked from
The journey's assistant lives in TREK Studio today, and the journal page is
about to get one of its own over the same threads. The chat and decision
bodies take an optional surface, studio or journal, and the journey scope
carries it to the service; left out it reads as the Studio, so today's
client is unchanged. The scope key ignores it, so both pages keep one
thread list per journey.
2026-10-24 22:54:42 +02:00
Maurice de497d15a4 test(assistant): the attachments repository on its own, every read, write and sweep step
The repositories are held to 99 percent of their statements, and the new
attachments repository was reached only through its services' mocks.
2026-10-24 22:32:48 +02:00
Maurice 05666295e0 test(assistant): every keep proposal in the attachments e2e takes a call id of its own
The refusal test proposed two keeps in one thread under the same call id, so
the second decision met the first call, already decided, and no refusal came
back. Each proposal now has its own id, and the refused right is set after the
proposal so it is the apply that refuses.
2026-10-24 22:32:48 +02:00
Maurice 62da83f5f7 test(assistant): the keep test reads the createFile call through a typed tuple
The mock's call list types as an empty tuple, so indexing its fourth argument
failed typecheck:tests.
2026-10-24 22:32:48 +02:00
Maurice b333910533 test(assistant): the attachments e2e provider answers the model list a saved connection asks for
Saving the custom connection lists the server's models first, and the fake
provider answered that with a 404, so the setup got 502. It answers /api/tags
the way the other assistant suites' fake provider does.
2026-10-24 22:32:48 +02:00
Maurice 2d1da19152 fix(assistant): the step that keeps an attached file's bytes is called stash
lint:test-sql counts every .prepare( under tests/ as a raw statement, and the
attachments service's prepare() was called five times in its unit test. The
test holds no SQL; the method takes a name that does not read as one.
2026-10-24 22:32:48 +02:00
Maurice e7ac46c756 fix(assistant): the keys of the attached-file answer are named where lint:i18n-keys sees them
The server built the answer through words.text(), words.count() and maps of
plain strings, so the check read all 13 assistant.attach keys as unused. The
helper's lookup is t() now (a numeric count picks the plural form, as the
client's does) and the chip and reason maps hold their keys under *Key
properties.
2026-10-24 22:32:48 +02:00
Maurice f1a2174f96 chore(assistant): the attachment work exports only what other files use
Types that only their own file reads lose their export, so knip's
unused-exports ratchet does not grow with the attachment work.
2026-10-24 22:32:48 +02:00
Maurice 89c33ac3f4 feat(assistant): an attached file can be kept in Files, on its own or with a booking or an expense
The chips offered to keep an attached file in Files, but nothing could do it:
the assistant's upload_trip_file stays denied, and the file never reached
Files. A new server write, keep_attached_file, is offered to the model where
the thread holds an attachment and held as a change card naming the file and
the booking, expense or place it goes with.

Applied, it runs the checks of a Files upload with the user's rights (the
demo block, the trip, file_upload and file_edit for a link, the instance's
file types, every linked id on the same trip), copies the bytes to a trip
file of their own, and writes the trip file, its expense link and the
attachment's pointer to it in one transaction; the members' planners get
file:created after it. A second keep of the same attachment answers the file
kept the first time. FilesService gains storeBytes and discardBytes so the
storage write stays out of that transaction.

The receipt opens the Files tab; the card's verb, its "Attached to" row and
the receipt's open label are new keys in all 27 locales. Undo is not offered:
no tool takes a trip file back yet.
2026-10-24 22:32:48 +02:00
Maurice aeda658f18 feat(assistant): the model reads an attached file once the user agrees
The assistant could not read a file the user attached: the bytes were gone
after the request, the thread only held the file's name, and read_trip_file
stays denied because it would send any document of the trip to the provider.

A new server tool, read_attached_file, reads only the files attached in this
conversation by this user; any other id is answered "No such attachment in
this conversation.", whoever it belongs to. It is offered in a trip turn whose
thread holds a file with text. The first read of a file is held as a change
card that names the file and the model and host its text would go to; once
the user agrees, the agreement is kept on the attachment and later reads run
at once. The text reaches the model through the history and, for a later
read, as a tool answer only the model gets (AgentToolRun.modelText): no message
part, stream chunk, shared answer or client store ever carries it.

The model is told each kept file's attachment id, and the standing
instructions say to read the file when the user wants to work with it, to
propose a booking found in it through create_reservation and then to offer
keeping the file with it. The card's verb, its "Sent to" row and the step's
name are new keys in all 27 locales.
2026-10-24 22:32:47 +02:00
Maurice 2f3a0219cd feat(assistant): every attached booking file gets an answer with what to do next
A booking file without a booking ended the answer on the error "No booking
found in this file." with no action, and the user was left to type. Now the
server answers every booking file itself, without a model: one line that
names the file and what TREK found in it, or that it found nothing, why, and
asks what to do with the file. Under it come up to three chips, picked from
what the user may do on the trip and what the file allows: keep it in Files,
make a booking from its text, attach it to a booking or an expense, ask what
is in it, or keep it with the booking just found. A chip sends its words as
an ordinary chat message.

The line and the chips are written in the language of the request from new
assistant.attach.* keys in all 27 locales, through a small helper the sports
proposals now share. The empty answer finishes as stop; no_bookings_found
stays in the contract for the threads older builds wrote. Route files keep
their answer as it was.
2026-10-24 22:32:47 +02:00
Maurice 41fb5759e2 feat(assistant): an attached file without a booking says why
The chat's booking reader kept only the bookings of the import preview and
dropped its warnings, so "AI reading is not set up", "the AI failed",
"a scan without text" and "nothing in it" all came out as the same
no_bookings_found. The reader now reads the reason from the preview (the mode
it ran in and the AI fallback's warnings) and keeps it in the stored read
step, and the note the model is sent names it.
2026-10-24 22:32:47 +02:00
Maurice 6a77496438 feat(assistant): an attached file stays with its conversation
A file attached in the trip chat lived only as a buffer for the one request
that read it, so the assistant could not look at it again and nothing could
keep it in Files. Now its bytes go to storage before the turn writes anything
(under the trip files' category, which is never served by path) and a row in
the new assistant_attachments table keeps its name, type, size, hash and the
text TREK read out of it, written in the turn's opening transaction. The
message's attachment part carries the row's id; old messages without one still
read.

The text is read the way the AI import reads it, through a new public
LlmParseService.readText, and a file whose text cannot be read is kept all the
same. A storage that refuses the bytes refuses the turn before any row exists.
Deleting a thread takes its files along, and the nightly retention sweep drops
files older than seven days and bytes no row names any more.
2026-10-24 22:32:47 +02:00
Maurice 487f8dd886 refactor(assistant): the answer to an attached file is a provider of its own
AssistantService sat three lines under the size limit, and the attachment
work ahead grows exactly this answer. Moving it into AssistantFileAnswers
leaves the rows and their transactions with the service, which hands the
answer the two ways it writes its first row. Nothing the user sees changes.
2026-10-24 22:32:47 +02:00
Maurice 09f286fbfe docs(readme): the feature list as 5.0 has it
Every count checked against the code (410 MCP tools, 35 scopes in 17 groups,
18 booking types, 17 trip permissions, 66 plugin permissions), and the parts
5.0 adds get their own place: Tours, TREK Studio, the AI assistant, the help
center, Road trip, Dawarich and the linked journal. Most lines are shorter.
2026-10-24 22:30:55 +02:00
530 changed files with 18749 additions and 2316 deletions
-1
View File
@@ -22,7 +22,6 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
+97 -67
View File
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
<table>
<tr>
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🧭 Planning
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
</td>
<td width="50%" valign="top">
#### 🧳 Bookings and money
#### 🥾 Tours
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
- **Multi-day tours**: one route cut into stages and spread across the trip's days
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧳 Bookings and money
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of nights with a check-in window
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
</td>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
#### 📔 Journal, Atlas and Vacay
</td>
</tr>
<tr>
<td width="50%" valign="top">
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
#### 📔 Journey and TREK Studio
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
</td>
<td width="50%" valign="top">
#### 🗺️ Atlas, Vacay and Collections
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🤖 AI assistant and MCP
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
</td>
</tr>
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 📱 Mobile and offline
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
</td>
</tr>
+3 -5
View File
@@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -41,9 +41,7 @@
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
@@ -94,7 +92,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"maplibre-gl": "6.15.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
+2 -2
View File
@@ -10,7 +10,7 @@
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 165,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1929,
"@typescript-eslint/no-non-null-assertion": 1927,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+16 -1
View File
@@ -661,9 +661,24 @@
"desktop": ["src/components/Roadtrip/RoadtripStayModal.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",
"hooks": ["src/components/Settings/useAccountSettings.ts"],
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
"desktop": ["src/components/Settings/AccountTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
},
-2
View File
@@ -55,7 +55,6 @@
"scripts/check-gl-split.mjs",
"scripts/check-page-pattern.mjs",
"scripts/generate-icons.mjs",
"scripts/patch-maplibre.mjs",
"src/App.test.tsx",
"src/App.tsx",
"src/App.versionHandover.test.tsx",
@@ -409,7 +408,6 @@
"src/components/Journey/usePhotoZoom.ts",
"src/components/Layout/BottomNav.test.tsx",
"src/components/Layout/BottomNav.tsx",
"src/components/Layout/DemoBanner.tsx",
"src/components/Layout/InAppNotificationBell.test.tsx",
"src/components/Layout/InAppNotificationBell.tsx",
"src/components/Layout/MobileTopBar.test.tsx",
-34
View File
@@ -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))
}
+1 -1
View File
@@ -6,7 +6,7 @@
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/PDF/TripPDF.tsx": 1082,
+1 -2
View File
@@ -51,7 +51,6 @@
"components/Journey/journeyMapPopup.ts": 17,
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
@@ -207,7 +206,7 @@
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 2,
"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/MSettingsMap.tsx": 5,
"mobile/screens/settings/MSettingsMcp.tsx": 30,
@@ -30,6 +30,7 @@ export default function AssistantSurface({
onRevealMap,
bookVersion,
onOpenTripChat,
journal,
}: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says.
@@ -43,8 +44,9 @@ export default function AssistantSurface({
onRevealMap,
bookVersion,
onOpenTripChat,
journal,
}),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat, journal]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
@@ -0,0 +1,36 @@
/**
* FE-AI-WELCOME-001 to FE-AI-WELCOME-002
*
* The empty chat asks what fits the chat it is: a journey's conversation
* shown for its journal asks about the journal, the same conversation in TREK
* Studio about the book, and each tells a reader what they may not change.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantWelcome from './AssistantWelcome';
import { AssistantViewContext } from './assistantViewContext';
const JOURNAL_KEY = -12;
function welcome(canEdit: boolean, journal?: boolean) {
return render(
<AssistantViewContext.Provider value={{ tripId: JOURNAL_KEY, variant: 'window', journal }}>
<AssistantWelcome suggestions={[]} canEdit={canEdit} withSuggestions={false} />
</AssistantViewContext.Provider>
);
}
describe('AssistantWelcome', () => {
it("FE-AI-WELCOME-001: a journey's chat shown for its journal asks about the journal", () => {
welcome(false, true);
expect(screen.getByText('What should go into the journal?')).toBeInTheDocument();
expect(screen.getByText(/You can only read this journal/)).toBeInTheDocument();
});
it('FE-AI-WELCOME-002: the same chat in the Studio asks about the book', () => {
welcome(true);
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
expect(screen.queryByText(/You can only/)).not.toBeInTheDocument();
});
});
@@ -27,33 +27,40 @@ export default function AssistantWelcome({
withSuggestions: boolean;
}) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
const scope = useAssistantScope().kind;
const book = scope === 'journey';
const { tripId, variant, journal } = useAssistantView();
// The Studio's chat is about a book, not a plan; the journey's chat shown for its journal about the journal,
// the home chat about all of the user's travels.
const about = useAssistantScope().kind;
const scope: WelcomeScope = about === 'journey' && journal ? 'journal' : about;
const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
</p>
)}
{!canEdit && <p className="m-0 max-w-xs text-center text-caption text-content-muted">{readOnlyNote(scope, t)}</p>}
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
</div>
);
}
/** What an empty chat is about: a trip, a journey's book in the Studio, a journal on its page, or all of them. */
type WelcomeScope = 'trip' | 'journey' | 'journal' | 'home';
/** The question over the empty chat, by what the chat is about. */
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
function welcomeTitle(scope: WelcomeScope, t: (key: string) => string): string {
if (scope === 'journey') return t('journey.studio.assistant.welcome');
if (scope === 'journal') return t('assistant.journal.welcome');
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
}
/** What the empty chat tells someone who may only read. */
function readOnlyNote(scope: WelcomeScope, t: (key: string) => string): string {
if (scope === 'journey') return t('journey.studio.assistant.readOnly');
return scope === 'journal' ? t('assistant.journal.readOnly') : t('assistant.empty.readOnly');
}
export function AssistantSuggestions({
suggestions,
layout,
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { HOME_CHAT_KEY, assistantScopeOf } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantFrameEdges from './AssistantFrameEdges';
import AssistantHeader from './AssistantHeader';
@@ -15,7 +15,7 @@ import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
import { plannerSearchOf } from './assistantShowOnMap';
import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes';
import { useAssistantHome } from './useAssistantHome';
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread';
@@ -33,7 +33,9 @@ import { useAssistantThread } from './useAssistantThread';
*
* Outside a trip it opens on the home chat, which knows all of the user's
* trips; its head lists them to go on in one trip's own chat, and a trip's
* chat opened that way has the way back home in its head.
* chat opened that way has the way back home in its head. On a journey's
* page it opens on that journal's chat, the journey's conversation it shares
* with TREK Studio.
*
* Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use.
@@ -170,12 +172,31 @@ function ChatWindow({
onFocusDay={onFocusDay}
onRevealMap={onRevealMap}
onOpenTripChat={onOpenTripChat}
// The window never shows the Studio: a journey's chat in it is the journal's.
journal={assistantScopeOf(trip.id).kind === 'journey'}
/>
</div>
</section>
);
}
/**
* Which chat the window shows: the page's own (the trip in the planner, else
* the journal on screen), else the one picked, else the home chat. `onScreen`
* when it is the page's own, `inPlanner` when that page is the planner.
*/
function windowChat(
trip: WindowTrip | null,
journal: WindowTrip | null,
picked: WindowTrip | null,
homeChat: WindowTrip
) {
const routeChat = trip ?? journal;
const current = routeChat ?? picked ?? homeChat;
const onScreen = !!routeChat && current.id === routeChat.id;
return { routeChat, current, onScreen, inPlanner: onScreen && trip !== null };
}
export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap }: AssistantWindowProps) {
const navigate = useNavigate();
const available = useAssistantStore((s) => s.status?.available === true);
@@ -198,13 +219,14 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
if (!open) setPicked(null);
}
const current = trip ?? picked ?? home.chat;
const journal = useRouteJournalChat();
const { routeChat, current, onScreen, inPlanner } = windowChat(trip, journal, picked, home.chat);
const atHome = current.id === HOME_CHAT_KEY;
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
// A change in a trip the planner does not show is shown by opening that trip (a journal, its page); a tab opens on it.
const openPicked = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
else if (picked) void navigate(`${chatPagePath(picked)}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
},
[navigate, picked]
);
@@ -216,19 +238,20 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
[navigate]
);
const goHome = useCallback(() => setPicked(null), []);
// Another page leaves the trip: the chat stays on it, as if it had been picked.
// Another page leaves the trip or the journal: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page' && trip) setPicked(trip);
if (target.kind === 'page' && routeChat) setPicked(routeChat);
onOpen?.(target);
},
[onOpen, trip]
[onOpen, routeChat]
);
// The round button's hint dot reads a trip's plan; a journal has none.
const launcherTrip = trip?.id ?? null;
if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!open) return routeChat ? <AssistantLauncher variant="floating" tripId={launcherTrip} /> : null;
const onScreen = !!trip && current.id === trip.id;
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
if (atHome) openTarget = openFromHome;
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
@@ -241,13 +264,13 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
hidden={minimized}
onOpen={openTarget}
// Only the planner on screen has days to select and a map to bring back.
onFocusDay={onScreen ? onFocusDay : undefined}
onRevealMap={onScreen ? onRevealMap : undefined}
onFocusDay={inPlanner ? onFocusDay : undefined}
onRevealMap={inPlanner ? onRevealMap : undefined}
onOpenTripChat={atHome ? home.openTripChat : undefined}
tripSwitch={tripSwitch}
/>
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
{minimized && <AssistantLauncher variant="floating" tripId={launcherTrip} focusOnMount />}
</>
);
}
@@ -121,4 +121,40 @@ describe('the questions an answer offers', () => {
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
});
it('FE-AI-FOLLOW-008: the answer to an attached file offers what the server chose, also under its booking cards', () => {
const read = {
type: 'tool-call' as const,
toolCallId: 'r',
toolName: 'read_booking_file',
args: { name: 'AirBnB Buchung.pdf' },
result: { bookings: [], reason: 'ai_not_configured' },
};
const chips = {
type: 'data-trek-follow-ups' as const,
data: { questions: ['Keep it in Files', 'What is in the file?'] },
};
const empty = of([
read,
{ type: 'text', text: 'I could not find a booking. What should I do with the file?' },
chips,
]);
expect(followUpsFor(empty, true).map((f) => f.text)).toEqual(['Keep it in Files', 'What is in the file?']);
const change = {
toolCallId: 'b',
verbKey: 'importBooking',
subject: 'Casa',
rows: [],
dayId: null,
placeIds: [],
undoable: false,
};
const found = of([
read,
{ type: 'text', text: 'I found 1 booking.' },
{ type: 'data-trek-change', data: change as AssistantChangePreview },
{ type: 'data-trek-follow-ups', data: { questions: ['Keep the file with this booking'] } },
]);
expect(followUpsFor(found, true).map((f) => f.text)).toEqual(['Keep the file with this booking']);
});
});
@@ -463,6 +463,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
sentTo: { labelKey: 'assistant.change.label.sentTo' },
attachedTo: { labelKey: 'assistant.change.label.attachedTo' },
};
/**
@@ -39,6 +39,12 @@ export interface AssistantView {
* chat's card that offers it; left out where no chat can be switched to.
*/
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
/**
* A journey's chat shown for its journal (on the journey's page, from the
* home chat), not for its book in TREK Studio; left out in the Studio and
* for a trip. The empty chat then speaks of the journal.
*/
journal?: boolean;
}
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
@@ -65,6 +65,13 @@ describe('cardLooks', () => {
}
});
it('FE-AI-LOOK-003b: a file kept from the chat opens the trip Files and shows nothing on the map', () => {
const kept: AssistantApplied = { toolCallId: 'k', entity: 'file', ids: [], dayId: null, undoUntil: null };
expect(ENTITY_LOOK.file.open?.(kept, null)).toEqual({ kind: 'tab', tab: 'dateien' });
expect(ENTITY_LOOK.file.onMap).toBeUndefined();
expect(ENTITY_LOOK.file.openLabelKey).toBe('assistant.card.file.open');
});
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
@@ -5,6 +5,9 @@ import {
CalendarMinus,
CalendarPlus,
Circle,
FileSearch,
FileText,
FolderInput,
ListTodo,
Luggage,
MapPin,
@@ -63,6 +66,8 @@ export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
readAttachment: { Icon: FileSearch, tone: 'info', labelKey: 'assistant.change.verb.readAttachment' },
keepFile: { Icon: FolderInput, tone: 'success', labelKey: 'assistant.change.verb.keepFile' },
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
@@ -159,6 +164,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
},
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
read_attached_file: { stepKey: 'assistant.step.readingAttachment' },
...TOUR_LOOK.steps,
...ROADTRIP_LOOK.steps,
...BUDGET_LOOK.steps,
@@ -193,6 +199,8 @@ export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook
assignment: { Icon: CalendarDays, onMap: BY_DAY },
day_note: { Icon: StickyNote, onMap: BY_DAY },
memory: { Icon: NotebookPen },
// A file kept from the chat: it opens the trip's Files.
file: { Icon: FileText, openLabelKey: 'assistant.card.file.open', open: () => ({ kind: 'tab', tab: 'dateien' }) },
other: { Icon: Circle },
...TOUR_LOOK.entities,
...ROADTRIP_LOOK.entities,
@@ -0,0 +1,49 @@
import { ArrowRight, MessageSquareText } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type AssistantChatTrip, useAssistantView } from '../../assistantViewContext';
/**
* What the home chat's cards that go on in another chat share: the question
* handed over, waiting there ready to send, and the button that switches to
* that chat. The button shows only where a chat can be switched to.
*/
/** The question that waits in the other chat's composer. */
export function HandedQuestion({ question }: { question: string | null }) {
const { t } = useTranslation();
if (!question) return null;
return (
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
</p>
);
}
/** The button that goes on in the chat under `chat`, with the question handed over. */
export function OpenChatButton({
chat,
question,
label,
}: {
chat: AssistantChatTrip;
question: string | null;
label: string;
}) {
const { onOpenTripChat, variant } = useAssistantView();
if (!onOpenTripChat) return null;
return (
<button
type="button"
onClick={() => onOpenTripChat(chat, question)}
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
variant === 'fullscreen' ? 'h-11' : 'h-9'
}`}
>
{label}
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
);
}
@@ -0,0 +1,72 @@
/**
* FE-AI-JOURNALCHAT-001 to FE-AI-JOURNALCHAT-003
*
* The home chat's card that offers a journal's own chat: the journal with
* its subtitle or days and what it holds, the question waiting there, and the
* button that goes on in the chat kept under the journey's key.
*/
import type { JournalChatCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { AssistantViewContext } from '../../assistantViewContext';
import { JournalChatBlock } from './JournalChatBlock';
const JAPAN: JournalChatCard = {
kind: 'journalChat',
journey: {
id: 12,
title: 'Japan 2026',
subtitle: null,
startDate: '2026-11-02',
endDate: '2026-11-16',
entryCount: 4,
photoCount: 120,
},
question: 'Make a new entry for Berlin Hauptbahnhof',
};
function drawn(
cards: JournalChatCard[],
onOpenTripChat?: (chat: { id: number; title: string }, q: string | null) => void
) {
return render(
<AssistantViewContext.Provider value={{ tripId: 0, variant: 'fullscreen', onOpenTripChat }}>
<JournalChatBlock cards={cards.map((card, index) => ({ toolCallId: `j${index}`, card }))} />
</AssistantViewContext.Provider>
);
}
describe('JournalChatBlock', () => {
it('FE-AI-JOURNALCHAT-001: shows the journal, its days, what it holds and the question', () => {
drawn([JAPAN]);
expect(screen.getByRole('region', { name: 'Continue in a journal' })).toBeInTheDocument();
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
expect(screen.getByText('4 entries')).toBeInTheDocument();
expect(screen.getByText('120 photos')).toBeInTheDocument();
expect(screen.getByText('Make a new entry for Berlin Hauptbahnhof')).toBeInTheDocument();
// Nowhere to switch to: no button.
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it("FE-AI-JOURNALCHAT-002: the button goes on in the journal's chat, under the journey's key", () => {
const open = vi.fn();
drawn([JAPAN], open);
fireEvent.click(screen.getByRole('button', { name: "Continue in this journal's chat" }));
expect(open).toHaveBeenCalledWith({ id: -12, title: 'Japan 2026' }, 'Make a new entry for Berlin Hauptbahnhof');
});
it('FE-AI-JOURNALCHAT-003: a subtitle stands for the days, an empty journal shows no photos, nothing draws nothing', () => {
const bare: JournalChatCard = {
...JAPAN,
journey: { ...JAPAN.journey, subtitle: 'Autumn', startDate: null, endDate: null, entryCount: 0, photoCount: 0 },
question: null,
};
drawn([bare]);
expect(screen.getByText('Autumn')).toBeInTheDocument();
expect(screen.getByText('0 entries')).toBeInTheDocument();
expect(screen.queryByText(/photos?$/)).not.toBeInTheDocument();
const { container } = render(<JournalChatBlock cards={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,76 @@
import type { JournalChatCard } from '@trek/shared';
import { BookOpen } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { assistantChatKey } from '../../../../store/assistantChatKey';
import { WhiteBadge } from '../../../Planner/planParts';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { useCardFormat } from '../parts/useCardFormat';
import { chatDates } from './chatDates';
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
/**
* The home chat's way onto a journal's own page: the journal under its head
* (its subtitle or days, how many entries and photos it holds), the question
* that waits there ready to send, and the button that goes on in the
* journal's chat. That chat is the journey's conversation on its page, kept
* under the journey's key; the window switches to it in place, the phone
* opens the journal with its chat.
*/
function JournalFacts({ card }: { card: JournalChatCard }) {
const { t } = useTranslation();
const { entryCount, photoCount } = card.journey;
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<WhiteBadge>{t('assistant.card.journey.entries', { count: entryCount })}</WhiteBadge>
{photoCount > 0 && <WhiteBadge>{t('assistant.card.journey.photos', { count: photoCount })}</WhiteBadge>}
</div>
);
}
function JournalChatSection({ card, first }: { card: JournalChatCard; first: boolean }) {
const { t } = useTranslation();
const format = useCardFormat();
const { journey, question } = card;
const meta = journey.subtitle ?? chatDates(t, format.date, journey.startDate, journey.endDate);
const chat = { id: assistantChatKey({ kind: 'journey', journeyId: journey.id }), title: journey.title };
return (
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
{first ? (
<CardHeader
icon={BookOpen}
tone="info"
eyebrow={t('assistant.card.journalChat.eyebrow')}
title={journey.title}
meta={meta}
>
<JournalFacts card={card} />
</CardHeader>
) : (
<div className="mb-2.5">
<bdi className="block truncate text-body font-bold text-content">{journey.title}</bdi>
<div className="mt-0.5 text-caption text-content-muted">{meta}</div>
<JournalFacts card={card} />
</div>
)}
<HandedQuestion question={question} />
<OpenChatButton chat={chat} question={question} label={t('assistant.card.journalChat.open')} />
</div>
);
}
export function JournalChatBlock({ cards }: { cards: readonly CardEntry[] }) {
const { t } = useTranslation();
const journals = cards.flatMap((entry) => (entry.card.kind === 'journalChat' ? [entry.card] : []));
if (journals.length === 0) return null;
return (
<CardFrame label={t('assistant.card.journalChat.label')}>
{journals.map((card, index) => (
<JournalChatSection key={`${card.journey.id}-${index}`} card={card} first={index === 0} />
))}
</CardFrame>
);
}
@@ -1,13 +1,14 @@
import type { TripChatCard, TripChatPhase } from '@trek/shared';
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
import { Luggage } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { useAssistantView } from '../../assistantViewContext';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { useCardFormat } from '../parts/useCardFormat';
import { chatDates } from './chatDates';
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
/**
* The home chat's way into one trip's own chat: the trip under its head (its
@@ -27,18 +28,9 @@ const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: stri
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
const { t } = useTranslation();
const { onOpenTripChat, variant } = useAssistantView();
const format = useCardFormat();
const { trip, question } = card;
const start = trip.startDate ?? trip.endDate;
const end = trip.endDate ?? trip.startDate;
let dates = t('assistant.card.tripChat.noDates');
if (start && end) {
dates =
start === end
? format.date(start)
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
}
const dates = chatDates(t, format.date, trip.startDate, trip.endDate);
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
return (
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
@@ -59,25 +51,12 @@ function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }
<TripFacts phase={phase} placeCount={trip.placeCount} />
</div>
)}
{question && (
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
</p>
)}
{onOpenTripChat && (
<button
type="button"
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
variant === 'fullscreen' ? 'h-11' : 'h-9'
}`}
>
{t('assistant.card.tripChat.open')}
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
)}
<HandedQuestion question={question} />
<OpenChatButton
chat={{ id: trip.id, title: trip.title }}
question={question}
label={t('assistant.card.tripChat.open')}
/>
</div>
);
}
@@ -0,0 +1,17 @@
/**
* The days a card of another chat names under its head: one day, a span, or
* that it has none yet. Pure, so the trip's card and the journal's read one
* wording.
*/
export function chatDates(
t: (key: string, params?: Record<string, string>) => string,
date: (value: string) => string,
startDate: string | null,
endDate: string | null
): string {
const start = startDate ?? endDate;
const end = endDate ?? startDate;
if (!start || !end) return t('assistant.card.tripChat.noDates');
if (start === end) return date(start);
return t('assistant.card.tripChat.dates', { start: date(start), end: date(end) });
}
@@ -13,9 +13,11 @@ export const HOME_LOOK = {
steps: {
list_trips: { stepKey: 'assistant.step.readingTrips' },
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
open_journey_chat: { stepKey: 'assistant.step.openJournalChat' },
},
entities: {},
cards: {
tripChat: {},
journalChat: {},
},
} satisfies PackLook<'tripChat'>;
} satisfies PackLook<'tripChat' | 'journalChat'>;
@@ -1,12 +1,14 @@
import type { CardView } from '../cardTypes';
import { JournalChatBlock } from './JournalChatBlock';
import { TripChatBlock } from './TripChatBlock';
/**
* How the home chat's kind draws: the trip it offers to go on with, beside
* the answer. This file only maps the kind to its view; the component lives
* How the home chat's kinds draw: the trip or the journal it offers to go on
* with, beside the answer. This file only maps the kind to its view; the component lives
* in a sibling file, since a file that exports anything besides components
* breaks fast refresh.
*/
export const HOME_VIEWS = {
tripChat: { Block: TripChatBlock },
} satisfies { [K in 'tripChat']: CardView<K> };
journalChat: { Block: JournalChatBlock },
} satisfies { [K in 'tripChat' | 'journalChat']: CardView<K> };
@@ -1,7 +1,20 @@
import type { AssistantChangeRow, JourneyEntriesCard } from '@trek/shared';
import { AlignLeft, CalendarDays, Clock, MapPin, Tag, Type } from 'lucide-react';
import {
AlignLeft,
ArrowDownUp,
CalendarDays,
Clock,
LocateFixed,
MapPin,
Tag,
Tags,
ThumbsDown,
ThumbsUp,
Type,
} from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { cardDateLabel } from '../../../Journey/journeyCard';
import type { ChangeItem } from '../../assistantParts';
import { FactChange } from '../parts/CardFacts';
import { EntryMarks, EntryStory, EntryThumb } from './JourneyEntryParts';
@@ -20,7 +33,9 @@ import { entryHoverProps, useEntryHover } from './useEntryHover';
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
/** The facts a journal change row names on its faint line, in this order: when, then where. */
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin } as const;
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin, coordinates: LocateFixed } as const;
/** The lists an update rewrites, each by its glyph: the tags and the two sides of the verdict. */
const LIST_ICONS: Readonly<Record<string, typeof Tag>> = { tags: Tags, pros: ThumbsUp, cons: ThumbsDown };
/** The words an update rewrites, named only when they change: the entry's title and its text. */
const WORD_ICONS = { name: Type, text: AlignLeft } as const;
type FactRow = AssistantChangeRow & { labelKey: keyof typeof FACT_ICONS };
@@ -45,7 +60,9 @@ function StateChange({ row }: { row: AssistantChangeRow }) {
if (flag) return <FactChange icon={flag.Icon} before={null} after={t(flag.labelKey)} tone="info" />;
const was = stateOf(row.field, row.before);
const now = stateOf(row.field, row.after);
const Icon = (now ?? was)?.Icon ?? Tag;
const listed =
row.field && Object.prototype.hasOwnProperty.call(LIST_ICONS, row.field) ? LIST_ICONS[row.field] : undefined;
const Icon = (now ?? was)?.Icon ?? listed ?? Tag;
const named = (tone: typeof now, value: string | null) => (tone ? t(tone.labelKey) : value);
return (
<FactChange
@@ -110,12 +127,32 @@ export function JourneyEntryAside({ card, item }: { card: JourneyEntriesCard; it
);
}
/** A new order: the entries that move, in their new places, each with its day; the rest counted. */
function NewOrder({ card }: { card: JourneyEntriesCard }) {
const { t, locale } = useTranslation();
return (
<ol className="m-0 mt-1 list-none space-y-0.5 p-0">
{card.entries.map((look, index) => (
<li key={look.entryId ?? index} className="flex items-center gap-1.5 text-caption text-content-secondary">
<ArrowDownUp size={11} aria-hidden="true" className="flex-none text-info" />
<bdi className="min-w-0 truncate">{look.title ?? look.place ?? cardDateLabel(look.date, locale)}</bdi>
<span className="flex-none text-content-faint">{cardDateLabel(look.date, locale)}</span>
</li>
))}
{card.more > 0 && (
<li className="text-caption text-content-faint">{t('assistant.card.common.more', { count: card.more })}</li>
)}
</ol>
);
}
/** The start of the entry's text and its marks, under the row; resting on them shows its place on the map. */
export function JourneyEntryBody({ card, item }: { card: JourneyEntriesCard; item: ChangeItem }) {
const { t } = useTranslation();
const hover = useEntryHover(card.journeyId, item.toolCallId);
const look = card.entries[0];
if (!look || GONE.has(item.state)) return null;
if (card.change === 'reordered') return <NewOrder card={card} />;
return (
<div {...entryHoverProps(hover, look)}>
<EntryStory look={look} roomy={false} />
@@ -0,0 +1,100 @@
/**
* FE-AI-JOURNEY-020 to FE-AI-JOURNEY-022
*
* The parts of a journal entry the chat added later: its tags and how its
* verdict leans among its marks, an update's tags, verdict and coordinates
* old to new, and a new order of the entries drawn as the entries that move.
*/
import type { AssistantChangePreview, JourneyEntriesCard, JourneyEntryLook } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../../../tests/helpers/render';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { AssistantViewContext } from '../../assistantViewContext';
const ENTRY: JourneyEntryLook = {
entryId: 12,
type: 'entry',
title: 'Sunset at the Spree',
date: '2026-10-21',
time: null,
place: null,
point: null,
countryCode: null,
story: null,
mood: null,
weather: null,
photoId: null,
video: false,
photoCount: 0,
visibility: 'private',
draft: false,
fromTripPlace: false,
dayIndex: null,
tags: ['river', 'evening'],
verdict: { pros: ['the light'], cons: ['cold', 'wind'], prosCount: 1, consCount: 2 },
};
function journal(entries: JourneyEntryLook[], change: JourneyEntriesCard['change'], more = 0): JourneyEntriesCard {
return { kind: 'journeyEntries', journeyId: 3, journalTitle: 'Berlin', tripJournal: false, change, entries, more };
}
function pending(
card: JourneyEntriesCard,
verbKey: AssistantChangePreview['verbKey'],
rows: AssistantChangePreview['rows']
) {
const preview: AssistantChangePreview = {
toolCallId: 'e1',
verbKey,
subject: 'Berlin',
rows,
dayId: null,
placeIds: [],
undoable: true,
domain: 'journey',
card,
};
const item: ChangeItem = { toolCallId: 'e1', preview, applied: null, state: 'pending', card };
return render(
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>
<AssistantChangeCard items={[item]} />
</AssistantViewContext.Provider>
);
}
describe('the later parts of a journal entry', () => {
it('FE-AI-JOURNEY-020: a new entry shows its tags and how its verdict leans', () => {
pending(journal([ENTRY], 'new'), 'addJournalEntry', []);
expect(screen.getByText('river')).toBeInTheDocument();
expect(screen.getByText('evening')).toBeInTheDocument();
expect(screen.getByText('1 good point')).toBeInTheDocument();
expect(screen.getByText('2 weak points')).toBeInTheDocument();
});
it('FE-AI-JOURNEY-021: an update names its tags, each side of its verdict and its coordinates, old to new', () => {
const rows: AssistantChangePreview['rows'] = [
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
];
pending(journal([{ ...ENTRY, tags: [], verdict: null }], 'changed'), 'updateJournalEntry', rows);
expect(screen.getByText('river, evening')).toHaveClass('line-through');
expect(screen.getByText('cold')).toBeInTheDocument();
expect(screen.getByText('52.5200, 13.4050')).toBeInTheDocument();
});
it('FE-AI-JOURNEY-022: a new order lists the entries that move in their new places, the rest counted', () => {
const moved = [
{ ...ENTRY, entryId: 2, title: 'Museum Island', moved: true },
{ ...ENTRY, entryId: 1, title: 'Arrival', moved: true },
];
pending(journal(moved, 'reordered', 3), 'reorderJournalEntries', []);
expect(screen.getByText('Put the entries in a new order')).toBeInTheDocument();
const text = document.body.textContent ?? '';
expect(text.indexOf('Museum Island')).toBeGreaterThan(-1);
expect(text.indexOf('Museum Island')).toBeLessThan(text.indexOf('Arrival'));
expect(screen.getByText('3 more')).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import type { JourneyEntryLook } from '@trek/shared';
import { CalendarDays, Globe, Images, MapPin, Play, Users } from 'lucide-react';
import { CalendarDays, Globe, Images, MapPin, Play, Tag, ThumbsDown, ThumbsUp, Users } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { cardDateLabel, cardPlace } from '../../../Journey/journeyCard';
@@ -101,11 +101,42 @@ function WeatherMark({ weather }: { weather: string | null }) {
);
}
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first. */
/** Its tags and how its verdict leans: the good and the weak points counted. */
function ListMarks({ look }: { look: JourneyEntryLook }) {
const { t } = useTranslation();
const verdict = look.verdict ?? null;
return (
<>
{(look.tags ?? []).map((tag, index) => (
<SoftPill
key={`${tag}-${index}`}
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
>
<bdi className="truncate">{tag}</bdi>
</SoftPill>
))}
{verdict && verdict.prosCount > 0 && (
<SoftPill tone="success" icon={<ThumbsUp size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.journey.pros', { count: verdict.prosCount })}
</SoftPill>
)}
{verdict && verdict.consCount > 0 && (
<SoftPill tone="warning" icon={<ThumbsDown size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.journey.cons', { count: verdict.consCount })}
</SoftPill>
)}
</>
);
}
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first, its tags and verdict. */
export function EntryMarks({ look }: { look: JourneyEntryLook }) {
const { t } = useTranslation();
const mood = moodOf(look.mood) !== null;
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2) return null;
const lists = (look.tags?.length ?? 0) > 0 || !!look.verdict;
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2 && !lists) {
return null;
}
return (
<CardFacts>
<VisibilityMark visibility={look.visibility} />
@@ -117,6 +148,7 @@ export function EntryMarks({ look }: { look: JourneyEntryLook }) {
{t('assistant.card.journey.photos', { count: look.photoCount })}
</SoftPill>
)}
<ListMarks look={look} />
</CardFacts>
);
}
@@ -0,0 +1,151 @@
/**
* FE-AI-JOURNEYHEADER-001 to FE-AI-JOURNEYHEADER-005
*
* A change of a journal itself in the chat: the settings an update sets by
* the journal's own names, old to new; the trips under the row with the one
* the change links or unlinks marked; what a new journal brings and what a
* deletion or an unlink takes; the receipt that opens the journal.
*/
import type { AssistantApplied, AssistantChangePreview, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { useAssistantStore } from '../../../../store/assistantStore';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { AssistantViewContext } from '../../assistantViewContext';
import { headerValueKey, settingRows } from './journeyHeaderModel';
const ASSISTANT = useAssistantStore.getState();
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
});
const TOKYO: JourneyHeaderTrip = {
id: 5,
title: 'Tokyo days',
startDate: '2026-11-02',
endDate: '2026-11-08',
change: 'kept',
};
const JAPAN: JourneyHeaderCard = {
kind: 'journeyHeader',
journeyId: 12,
title: 'Japan 2026',
subtitle: null,
change: 'changed',
trips: [TOKYO],
entries: 3,
photos: 40,
suggestions: null,
};
function change(
state: ChangeItem['state'],
card: JourneyHeaderCard,
over: Partial<AssistantChangePreview> = {}
): ChangeItem {
const preview: AssistantChangePreview = {
toolCallId: 'h1',
verbKey: 'updateJournalSettings',
subject: 'Japan 2026',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
domain: 'journey',
card,
...over,
};
const applied: AssistantApplied = { toolCallId: 'h1', entity: 'journey', ids: [12], dayId: null, undoUntil: null };
return { toolCallId: 'h1', preview, applied: state === 'applied' ? applied : null, state, card };
}
function shown(item: ChangeItem, onOpen?: (target: unknown) => void) {
return render(
<AssistantViewContext.Provider value={{ tripId: -12, variant: 'window', onOpen }}>
<AssistantChangeCard items={[item]} />
</AssistantViewContext.Provider>
);
}
describe('a change of a journal itself', () => {
it('FE-AI-JOURNEYHEADER-001: an update names each setting by the journal own words, old to new', () => {
const rows: AssistantChangePreview['rows'] = [
{ op: 'change', labelKey: 'name', before: 'Japan', after: 'Japan 2026' },
{ op: 'change', labelKey: 'field', field: 'show_mood', before: '✓', after: '✗' },
{ op: 'change', labelKey: 'field', field: 'status_override', before: '', after: 'live' },
];
shown(change('pending', JAPAN, { rows }));
expect(screen.getByText("Change the journal's settings")).toBeInTheDocument();
expect(screen.getByText('Japan')).toHaveClass('line-through');
expect(screen.getByText('Mood: On')).toHaveClass('line-through');
expect(screen.getByText('Mood: Off')).toBeInTheDocument();
expect(screen.getByText('Shown as: Automatic')).toHaveClass('line-through');
expect(screen.getByText('Shown as: Live')).toBeInTheDocument();
expect(screen.getByText('Tokyo days')).toBeInTheDocument();
});
it('FE-AI-JOURNEYHEADER-002: a new journal shows its subtitle, the trips it links and the places that come in', () => {
const fresh: JourneyHeaderCard = {
...JAPAN,
journeyId: null,
change: 'new',
subtitle: 'Autumn',
trips: [{ ...TOKYO, change: 'link' }],
entries: null,
photos: null,
suggestions: 9,
};
shown(change('pending', fresh, { verbKey: 'createJournal', undoable: true }));
expect(screen.getByText('Start a journal')).toBeInTheDocument();
expect(screen.getByText('Autumn')).toBeInTheDocument();
expect(screen.getByText('Will be added')).toBeInTheDocument();
expect(screen.getByText('9 places come in as suggestions')).toBeInTheDocument();
});
it('FE-AI-JOURNEYHEADER-003: a deletion warns that everything goes; an unlink says what stays and what goes', () => {
const first = shown(change('pending', { ...JAPAN, change: 'willDelete' }, { verbKey: 'deleteJournal' }));
expect(screen.getByText('Delete the journal')).toBeInTheDocument();
expect(screen.getByText(/This cannot be undone\./)).toBeInTheDocument();
first.unmount();
const unlink: JourneyHeaderCard = {
...JAPAN,
trips: [{ ...TOKYO, change: 'unlink' }],
entries: 1,
suggestions: 4,
};
shown(change('pending', unlink, { verbKey: 'unlinkJournalTrip' }));
expect(screen.getByText('Tokyo days')).toHaveClass('line-through');
expect(screen.getByText('Will be taken out')).toBeInTheDocument();
expect(screen.getByText('1 written entry stays')).toBeInTheDocument();
expect(screen.getByText('4 open suggestions go')).toBeInTheDocument();
});
it('FE-AI-JOURNEYHEADER-004: the receipt opens the journal, a change that failed shows its row alone', () => {
const onOpen = vi.fn();
const applied = shown(change('applied', JAPAN), onOpen);
fireEvent.click(screen.getByRole('button', { name: 'Open the journal' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/12' });
applied.unmount();
shown(change('failed', JAPAN));
expect(screen.queryByText('Tokyo days')).toBeNull();
});
it('FE-AI-JOURNEYHEADER-005: the model reads a status by its name and a switch as on or off', () => {
expect(headerValueKey('status', 'archived')).toBe('journey.status.archived');
expect(headerValueKey('status_override', null)).toBe('journey.settings.statusAuto');
expect(headerValueKey('show_mood', '✓')).toBe('settings.on');
expect(headerValueKey('cover_gradient', 'linear-gradient(red, blue)')).toBeNull();
expect(headerValueKey('status', 'toString')).toBeNull();
const rows: AssistantChangePreview['rows'] = [
{ op: 'add', labelKey: 'field', field: 'trip', before: null, after: 'Kyoto' },
{ op: 'add', labelKey: 'field', field: 'show_weather', before: null, after: '✓' },
{ op: 'add', labelKey: 'field', field: 'unknown', before: null, after: 'x' },
{ op: 'change', labelKey: 'name', before: 'A', after: 'B' },
];
expect(settingRows(rows).map((row) => row.field)).toEqual(['show_weather']);
});
});
@@ -0,0 +1,148 @@
import type { AssistantChangeRow, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
import { AlignLeft, Luggage, Type } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type PillTone, SoftPill } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
import { FactChange } from '../parts/CardFacts';
import { useCardFormat } from '../parts/useCardFormat';
import { headerFieldOf, headerValueKey, settingRows } from './journeyHeaderModel';
/**
* What a change row of a journal itself adds to the row it already is (its
* verb and the journal's name): on the faint line the name, subtitle and
* settings it changes, each old to new; under the row the journal's trips,
* the one the change links or unlinks marked, and what the change brings or
* takes along. A change that failed, was discarded or ran out shows the row
* alone.
*/
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
const WORD_ICONS = { name: Type, description: AlignLeft } as const;
const NOTE = 'm-0 mt-1.5 text-caption';
const TRIP_TONE: Readonly<Record<JourneyHeaderTrip['change'], PillTone>> = {
kept: 'neutral',
link: 'success',
unlink: 'danger',
};
/** One setting a change sets, by its name, old to new. */
function SettingChange({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const look = headerFieldOf(row.field);
if (!look) return null;
const named = (value: string | null) => {
const key = headerValueKey(row.field, value);
if (key) return t(key);
return value || null;
};
const before = row.op === 'add' ? null : named(row.before);
const after = named(row.after) ?? t('assistant.card.common.removed');
const name = t(look.labelKey);
return (
<FactChange
icon={look.Icon}
before={before === null ? null : t('assistant.card.journeyHeader.setting', { name, value: before })}
after={t('assistant.card.journeyHeader.setting', { name, value: after })}
tone="info"
/>
);
}
/** The journal's new name and subtitle and the settings a change sets. */
export function JourneyHeaderFacts({ item }: { card: JourneyHeaderCard; item: ChangeItem }) {
const { t } = useTranslation();
const rows = item.preview?.rows ?? [];
const words = rows.filter(
(row): row is AssistantChangeRow & { labelKey: keyof typeof WORD_ICONS } =>
row.op === 'change' && row.labelKey in WORD_ICONS
);
return (
<>
{words.map((row) => (
<FactChange
key={row.labelKey}
icon={WORD_ICONS[row.labelKey]}
before={row.before || null}
after={row.after || t('assistant.card.common.removed')}
tone="info"
/>
))}
{settingRows(rows).map((row) => (
<SettingChange key={row.field} row={row} />
))}
</>
);
}
function TripChip({ trip }: { trip: JourneyHeaderTrip }) {
const { t } = useTranslation();
const format = useCardFormat();
const days = trip.startDate ? format.date(trip.startDate) : null;
return (
<SoftPill
tone={TRIP_TONE[trip.change]}
className="max-w-full"
icon={<Luggage size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
>
{trip.change === 'link' && <span className="sr-only">{t('assistant.card.journeyHeader.tripLink')}</span>}
{trip.change === 'unlink' && <span className="sr-only">{t('assistant.card.journeyHeader.tripUnlink')}</span>}
<bdi className={`truncate ${trip.change === 'unlink' ? 'line-through' : ''}`}>{trip.title}</bdi>
{days && <span className="text-content-faint">{days}</span>}
</SoftPill>
);
}
/** What the change brings along or takes with it, in the journal's words. */
function HeaderNotes({ card }: { card: JourneyHeaderCard }) {
const { t } = useTranslation();
const unlinking = card.trips.some((trip) => trip.change === 'unlink');
if (card.change === 'willDelete') {
return <p className={`${NOTE} text-danger`}>{t('assistant.card.journeyHeader.deletes')}</p>;
}
if (unlinking) {
return (
<>
{card.entries !== null && card.entries > 0 && (
<p className={`${NOTE} text-content-muted`}>
{t('assistant.card.journeyHeader.entriesStay', { count: card.entries })}
</p>
)}
{card.suggestions !== null && card.suggestions > 0 && (
<p className={`${NOTE} text-content-muted`}>
{t('assistant.card.journeyHeader.suggestionsGo', { count: card.suggestions })}
</p>
)}
</>
);
}
if (card.suggestions === null || card.suggestions === 0) return null;
return (
<p className={`${NOTE} text-content-muted`}>
{t('assistant.card.journeyHeader.suggestionsCome', { count: card.suggestions })}
</p>
);
}
/** The journal's trips and what the change brings or takes, under the row. */
export function JourneyHeaderBody({ card, item }: { card: JourneyHeaderCard; item: ChangeItem }) {
if (GONE.has(item.state)) return null;
return (
<div>
{card.subtitle && card.change === 'new' && (
<p className="m-0 text-caption text-content-muted">
<bdi>{card.subtitle}</bdi>
</p>
)}
{card.trips.length > 0 && (
<div className="mt-1 flex flex-wrap items-center gap-1">
{card.trips.map((trip, index) => (
<TripChip key={`${trip.id ?? 'x'}-${index}`} trip={trip} />
))}
</div>
)}
<HeaderNotes card={card} />
</div>
);
}
@@ -0,0 +1,105 @@
/**
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
*
* A change of a journal's photos in the chat: the entries the photos leave
* and reach, the photos as thumbnails, a caption old to new, what stays
* where, and the receipt that opens the entry.
*/
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { AssistantViewContext } from '../../assistantViewContext';
const MOVE: JourneyPhotosCard = {
kind: 'journeyPhotos',
journeyId: 3,
mode: 'move',
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
photos: [
{ photoId: 101, video: false, caption: 'Black sand' },
{ photoId: 102, video: true, caption: null },
],
more: 4,
kept: false,
caption: null,
};
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
const preview: AssistantChangePreview = {
toolCallId: 'p1',
verbKey,
subject: 'Reynisfjara',
rows: [],
dayId: null,
placeIds: [],
undoable: false,
domain: 'journey',
card,
};
const receipt: AssistantApplied = {
toolCallId: 'p1',
entity: 'journey_entry',
ids: [2],
dayId: null,
undoUntil: null,
};
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
}
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
return render(
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
<AssistantChangeCard items={[item]} />
</AssistantViewContext.Provider>
);
}
describe('a change of a journal photos', () => {
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
expect(screen.getByText('Vik')).toBeInTheDocument();
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
expect(screen.getByText('4 more')).toBeInTheDocument();
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
first.unmount();
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
expect(screen.getByText('Entry cover')).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
const caption = {
...MOVE,
mode: 'caption' as const,
from: null,
to: null,
more: 0,
photos: [{ photoId: 101, video: false, caption: 'Old' }],
};
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
expect(screen.getByText('Old')).toHaveClass('line-through');
expect(screen.getByText('New')).toBeInTheDocument();
first.unmount();
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
expect(screen.getByText('Removed')).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
const onOpen = vi.fn();
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
});
});
@@ -0,0 +1,107 @@
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { cardDateLabel } from '../../../Journey/journeyCard';
import { SoftPill } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
import { FactChange } from '../parts/CardFacts';
/**
* What a change row of a journal's photos adds under the row: the entries
* the photos leave and reach, the photos themselves as small thumbnails (the
* first marked as the entry's cover in a new order), a caption old to new,
* and what the change leaves where. A change that failed, was discarded or
* ran out shows the row alone.
*/
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
const { locale } = useTranslation();
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
}
/** The entries the photos leave and reach, the way between them marked. */
function Route({ card }: { card: JourneyPhotosCard }) {
if (!card.from && !card.to) return null;
return (
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
{card.from && <EntryName entry={card.from} />}
{card.from && card.to && (
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
)}
{card.to && <EntryName entry={card.to} />}
</p>
);
}
function Thumbs({ card }: { card: JourneyPhotosCard }) {
const { t } = useTranslation();
if (card.photos.length === 0 && card.more === 0) return null;
return (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{card.photos.map((photo, index) => (
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
<img
src={`/api/photos/${photo.photoId}/thumbnail`}
alt={photo.caption ?? ''}
loading="lazy"
className="size-full rounded-lg bg-surface-secondary object-cover"
/>
{photo.video && (
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
<span className="sr-only">{t('assistant.card.journey.video')}</span>
</SoftPill>
)}
{card.mode === 'order' && index === 0 && (
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
)}
</span>
))}
{card.more > 0 && (
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.common.more', { count: card.more })}
</SoftPill>
)}
</div>
);
}
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
function Notes({ card }: { card: JourneyPhotosCard }) {
const { t } = useTranslation();
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
if (card.mode === 'unlink' || card.mode === 'move') {
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
}
if (card.mode === 'order' && card.photos.length > 0) {
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
}
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
return null;
}
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
const { t } = useTranslation();
if (GONE.has(item.state)) return null;
return (
<div>
<Route card={card} />
<Thumbs card={card} />
{card.caption && (
<span className="mt-1.5 flex">
<FactChange
icon={MessageSquareText}
before={card.caption.before}
after={card.caption.after ?? t('assistant.card.common.removed')}
tone="info"
/>
</span>
)}
<Notes card={card} />
</div>
);
}
@@ -0,0 +1,82 @@
import type { AssistantChangeRow } from '@trek/shared';
import {
CircleDot,
CloudSun,
Eye,
EyeOff,
Footprints,
Luggage,
MapPin,
Palette,
Route,
Smile,
Sparkles,
ThumbsUp,
type LucideIcon,
} from 'lucide-react';
/**
* The pure side of a change of a journal itself: the glyph and name of each
* setting a change row names (under the tool's own field names), and how its
* value reads, a status by its name, a switch as on or off, a cleared status
* override as following the trip dates. The names are the journal's own,
* as its settings dialog writes them.
*/
interface FieldLook {
Icon: LucideIcon;
labelKey: string;
}
const FIELDS: Readonly<Record<string, FieldLook>> = {
status: { Icon: CircleDot, labelKey: 'journey.settings.status' },
status_override: { Icon: Eye, labelKey: 'assistant.card.journeyHeader.field.statusOverride' },
show_verdict: { Icon: ThumbsUp, labelKey: 'journey.settings.showVerdict' },
show_mood: { Icon: Smile, labelKey: 'journey.settings.showMood' },
show_weather: { Icon: CloudSun, labelKey: 'journey.settings.showWeather' },
photo_location: { Icon: MapPin, labelKey: 'journey.settings.photoLocation' },
show_trip_tracks: { Icon: Route, labelKey: 'journey.settings.showTripTracks' },
auto_prepare_recordings: { Icon: Footprints, labelKey: 'journey.settings.autoPrepare' },
cover_gradient: { Icon: Palette, labelKey: 'assistant.card.journeyHeader.field.coverGradient' },
trip: { Icon: Luggage, labelKey: 'assistant.card.journeyHeader.field.trip' },
suggestions: { Icon: Sparkles, labelKey: 'assistant.card.journeyHeader.field.suggestions' },
hide_skeletons: { Icon: EyeOff, labelKey: 'journey.skeletons.hide' },
};
const STATUSES: Readonly<Record<string, string>> = {
draft: 'journey.status.draft',
active: 'journey.status.active',
completed: 'journey.status.completed',
archived: 'journey.status.archived',
live: 'journey.frontpage.live',
};
const FLAG_ON = '✓';
const FLAG_OFF = '✗';
function own<T>(table: Readonly<Record<string, T>>, key: string | null | undefined): T | null {
if (!key || !Object.prototype.hasOwnProperty.call(table, key)) return null;
return table[key] ?? null;
}
/** The look of a setting a row names; null for a field the card has no name for. */
export function headerFieldOf(field: string | undefined): FieldLook | null {
return own(FIELDS, field);
}
/**
* The key a value of a setting reads by, or null for one that reads as it is
* (a name, a count). A cleared status override follows the trip dates again.
*/
export function headerValueKey(field: string | undefined, value: string | null): string | null {
if (value === FLAG_ON) return 'settings.on';
if (value === FLAG_OFF) return 'settings.off';
if (field === 'status_override' && !value) return 'journey.settings.statusAuto';
if (field === 'status' || field === 'status_override') return own(STATUSES, value);
return null;
}
/** The setting rows of a change, the trips aside: the card draws those as its own chips. */
export function settingRows(rows: readonly AssistantChangeRow[]): AssistantChangeRow[] {
return rows.filter((row) => row.labelKey === 'field' && row.field !== 'trip' && headerFieldOf(row.field) !== null);
}
@@ -1,4 +1,22 @@
import { BookOpen, BookOpenText, BookPlus, BookX } from 'lucide-react';
import {
ArrowDownUp,
ArrowRightLeft,
BookCheck,
BookOpen,
BookOpenText,
BookPlus,
BookX,
Eye,
ImageMinus,
ImagePlus,
Images,
Link,
MessageSquareText,
RotateCcw,
Settings2,
Type,
Unlink,
} from 'lucide-react';
import type { PackLook } from '../cardTypes';
import { journalTarget } from './journeyCardModel';
@@ -17,6 +35,34 @@ export const JOURNEY_LOOK = {
addJournalEntry: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.addJournalEntry' },
updateJournalEntry: { Icon: BookOpenText, tone: 'info', labelKey: 'assistant.change.verb.updateJournalEntry' },
removeJournalEntry: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.removeJournalEntry' },
reorderJournalEntries: {
Icon: ArrowDownUp,
tone: 'info',
labelKey: 'assistant.change.verb.reorderJournalEntries',
},
createJournal: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.createJournal' },
renameJournal: { Icon: Type, tone: 'info', labelKey: 'assistant.change.verb.renameJournal' },
setJournalStatus: { Icon: BookCheck, tone: 'info', labelKey: 'assistant.change.verb.setJournalStatus' },
updateJournalSettings: { Icon: Settings2, tone: 'info', labelKey: 'assistant.change.verb.updateJournalSettings' },
deleteJournal: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.deleteJournal' },
linkJournalTrip: { Icon: Link, tone: 'success', labelKey: 'assistant.change.verb.linkJournalTrip' },
unlinkJournalTrip: { Icon: Unlink, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalTrip' },
restoreJournalSuggestions: {
Icon: RotateCcw,
tone: 'info',
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
},
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
captionJournalPhoto: {
Icon: MessageSquareText,
tone: 'info',
labelKey: 'assistant.change.verb.captionJournalPhoto',
},
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
},
tools: {
create_journey: { labelKey: 'assistant.tool.create_journey' },
@@ -35,10 +81,21 @@ export const JOURNEY_LOOK = {
journey_entry: {
Icon: BookOpen,
openLabelKey: 'assistant.card.journey.open',
open: (applied, card) =>
card?.kind === 'journeyEntries'
open: (applied, card) => {
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
return card?.kind === 'journeyEntries'
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
: null,
: null;
},
},
journey: {
Icon: BookOpen,
openLabelKey: 'assistant.card.journeyHeader.open',
open: (applied, card) => {
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
const journeyId = applied.ids[0] ?? fromCard;
return journeyId ? journalTarget(journeyId, null, false) : null;
},
},
},
cards: {
@@ -59,5 +116,11 @@ export const JOURNEY_LOOK = {
return readIds.length > 0 && readIds.every((id) => id !== null && touched.has(id));
},
},
journeyHeader: {
entity: () => 'journey',
},
journeyPhotos: {
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
},
},
} satisfies PackLook<'journeyEntries'>;
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
@@ -1,12 +1,15 @@
import type { CardView } from '../cardTypes';
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
import JourneyEntriesBlock from './JourneyEntriesBlock';
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
import { JourneyPhotosBody } from './JourneyPhotosSlots';
/**
* How the kinds of the journeys draw in the chat: the entries a read found
* as a block of their own, and a change row of an entry with its photo beside
* the subject, its day, time and place and whatever an update changes on the
* faint line, and the start of its text under it. This file only maps each
* faint line, and the start of its text under it; a change of the journal
* itself with its settings on the faint line and its trips under it. This file only maps each
* kind to its view: the components live in sibling files, since a file that
* exports anything besides components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
@@ -18,4 +21,11 @@ export const JOURNEY_VIEWS = {
Facts: JourneyEntryFacts,
Body: JourneyEntryBody,
},
} satisfies { [K in 'journeyEntries']: CardView<K> };
journeyHeader: {
Facts: JourneyHeaderFacts,
Body: JourneyHeaderBody,
},
journeyPhotos: {
Body: JourneyPhotosBody,
},
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };
@@ -1,18 +1,19 @@
/**
* FE-AI-HOME-001 to FE-AI-HOME-004
* FE-AI-HOME-001 to FE-AI-HOME-006
*
* The home chat's shared hooks: its key and name, the way into one trip's own
* chat with the question waiting there (a question already typed there
* stays), the way in from the pages outside a trip, and the questions the
* empty home chat offers.
* empty home chat offers, and the journal on screen as a chat.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useJourneyStore } from '../../store/journeyStore';
import { HOME_SUGGESTIONS } from './assistantModel';
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
import { chatPagePath, useAssistantHome, useAssistantHomeLauncher, useRouteJournalChat } from './useAssistantHome';
import { useAssistantSuggestions } from './useAssistantSuggestions';
const INITIAL = useAssistantStore.getState();
@@ -100,3 +101,29 @@ describe('the empty home chat', () => {
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
});
});
function RouteJournal() {
const chat = useRouteJournalChat();
return <p>{chat ? `journal ${chat.id} ${chat.title}` : 'no journal'}</p>;
}
describe('the journal on screen', () => {
it("FE-AI-HOME-005: a journey's page is its journal's chat, under the journey's key, but not in its Studio", () => {
useJourneyStore.setState({ current: { id: 12, title: 'Japan 2026' } as never });
render(<RouteJournal />, { initialEntries: ['/journey/12'] });
expect(screen.getByText('journal -12 Japan 2026')).toBeInTheDocument();
render(<RouteJournal />, { initialEntries: ['/journey/12/studio'] });
expect(screen.getAllByText('no journal')).toHaveLength(1);
render(<RouteJournal />, { initialEntries: ['/journey'] });
expect(screen.getAllByText('no journal')).toHaveLength(2);
// Another journey loaded: the key is the route's, the title waits for its own.
render(<RouteJournal />, { initialEntries: ['/journey/13'] });
expect(screen.getByText(/^journal -13\s*$/)).toBeInTheDocument();
useJourneyStore.setState({ current: null });
});
it('FE-AI-HOME-006: a chat switched to from home opens on its own page', () => {
expect(chatPagePath({ id: 5, title: 'Kyoto' })).toBe('/trips/5');
expect(chatPagePath({ id: -12, title: 'Japan 2026' })).toBe('/journey/12');
});
});
@@ -1,8 +1,10 @@
import { useCallback, useMemo } from 'react';
import { useMatch } from 'react-router';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { assistantChatKey, assistantScopeOf, HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import { useJourneyStore } from '../../store/journeyStore';
import type { AssistantChatTrip } from './assistantViewContext';
/**
@@ -42,3 +44,27 @@ export function useAssistantHomeLauncher() {
const open = useAssistantStore((s) => s.open);
return { available, open };
}
/**
* The journal on screen as a chat: on a journey's own page the journey's
* conversation (the one TREK Studio holds too), under its key and with the
* journey's title once the page has loaded it; null elsewhere and in the
* Studio, whose column holds that chat itself.
*/
export function useRouteJournalChat(): AssistantChatTrip | null {
const match = useMatch('/journey/:id/*');
const inStudio = useMatch('/journey/:id/studio/*') !== null;
const id = Number(match?.params.id);
const journeyId = !inStudio && Number.isInteger(id) && id > 0 ? id : null;
const title = useJourneyStore((s) => (s.current && s.current.id === journeyId ? s.current.title : ''));
return useMemo(
() => (journeyId === null ? null : { id: assistantChatKey({ kind: 'journey', journeyId }), title }),
[journeyId, title]
);
}
/** The page a chat switched to from the home chat lives on: a journey's for its key, a trip's otherwise. */
export function chatPagePath(chat: AssistantChatTrip): string {
const scope = assistantScopeOf(chat.id);
return scope.kind === 'journey' ? `/journey/${scope.journeyId}` : `/trips/${chat.id}`;
}
@@ -140,7 +140,17 @@ export function ChatBubble({
>
{reply.name}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{/* Wrapped and cut after two lines: a quote kept on one line set the bubble's width and pushed it out of the chat. */}
<div
style={{
opacity: 0.8,
overflow: 'hidden',
whiteSpace: 'normal',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{reply.text}
</div>
</div>
@@ -26,6 +26,8 @@ describe('the Collab pieces', () => {
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
expect(screen.getByText('Anna')).toBeInTheDocument();
expect(screen.getByText('Train?')).toBeInTheDocument();
// A long quote wraps instead of setting the bubble's width (it pushed the bubble out of the chat).
expect(screen.getByText('Train?').getAttribute('style')).toContain('white-space: normal');
});
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import {
PHOTO_CREDITS,
PUBLIC_DOMAIN,
commonsPage,
creditTitle,
licenseDeed,
type PhotoCredit,
} from './demoCreditsModel';
type Translate = ReturnType<typeof useTranslation>['t'];
const LINK = 'underline decoration-edge underline-offset-2 hover:text-content';
/** One photo: its title linking to its page on Commons, who took it, and its license linking to the deed. */
function CreditRow({ credit, t }: { credit: PhotoCredit; t: Translate }) {
const deed = licenseDeed(credit.license);
const license = credit.license === PUBLIC_DOMAIN ? t('demo.credits.publicDomain') : credit.license;
return (
<li className="flex flex-col gap-0.5 py-2.5">
<a
href={commonsPage(credit.file)}
target="_blank"
rel="noopener noreferrer"
className={`${LINK} self-start break-words font-medium text-content`}
style={fs(12.5, 'body')}
>
{creditTitle(credit.file)}
</a>
<span className="flex flex-wrap gap-x-3 gap-y-0.5 text-content-muted" style={fs(12, 'body')}>
<span>{t('demo.credits.by', { author: credit.author })}</span>
{deed ? (
<a href={deed} target="_blank" rel="noopener noreferrer" className={LINK}>
{license}
</a>
) : (
<span>{license}</span>
)}
</span>
</li>
);
}
/**
* The credits for the demo's photos, the welcome dialog's second view. Its own
* chunk, loaded when a visitor opens it: only a demo instance shows the
* welcome, and few visitors open its credits.
*/
export default function DemoCredits() {
const { t } = useTranslation();
const intro = useRef<HTMLParagraphElement>(null);
// The dialog's body keeps the welcome's scroll position when the credits
// take its place; they start at their top.
useEffect(() => {
intro.current?.scrollIntoView({ block: 'nearest' });
}, []);
return (
<>
<p ref={intro} className="m-0 scroll-mt-5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
{t('demo.credits.intro')}
</p>
<ul
aria-label={t('demo.credits.title')}
className="m-0 list-none divide-y divide-edge-faint rounded-[16px] border border-edge-faint bg-surface-card px-3.5"
>
{PHOTO_CREDITS.map((credit) => (
<CreditRow key={credit.file} credit={credit} t={t} />
))}
</ul>
</>
);
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import DemoLock from './DemoLock';
describe('DemoLock', () => {
it('FE-COMP-DEMOLOCK-001: unlocked, the section is the section and nothing more', () => {
const { container } = render(
<DemoLock locked={false} reason="Not here.">
<button type="button">Create key</button>
</DemoLock>
);
expect(screen.getByRole('button', { name: 'Create key' })).toBeEnabled();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(container.querySelector('[data-demo-lock]')).toBeNull();
});
it('FE-COMP-DEMOLOCK-002: locked, the section stays visible but out of reach, under the badge and the reason', () => {
const { container } = render(
<DemoLock locked reason="API keys cannot be created in the demo.">
<button type="button">Create key</button>
</DemoLock>
);
const note = screen.getByRole('note');
expect(note).toHaveTextContent('Demo mode');
expect(note).toHaveTextContent('API keys cannot be created in the demo.');
expect(screen.queryByRole('button', { name: 'Create key' })).not.toBeInTheDocument();
const veiled = container.querySelector('[data-demo-lock] > [inert]');
expect(veiled).toHaveAttribute('aria-hidden', 'true');
expect(veiled).toContainElement(screen.getByRole('button', { hidden: true, name: 'Create key' }));
expect(note.getAttribute('style')).toContain('repeating-linear-gradient');
expect(note.getAttribute('style')).toContain('var(--text-primary)');
});
it('FE-COMP-DEMOLOCK-003: a single row puts the note in its middle, a section at its top', () => {
const { rerender } = render(
<DemoLock locked reason="r" size="row">
<span>row</span>
</DemoLock>
);
expect(screen.getByRole('note').className).toContain('items-center');
rerender(
<DemoLock locked reason="r">
<span>section</span>
</DemoLock>
);
expect(screen.getByRole('note').className).toContain('items-start');
});
});
+57
View File
@@ -0,0 +1,57 @@
import { Lock } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
interface DemoLockProps {
/** From useDemoAccountLock: false renders the children untouched. */
locked: boolean;
/** One line saying what the demo does not allow here. */
reason: string;
children: ReactNode;
/** `row` for a single settings row: the note sits in the middle instead of at the top. */
size?: 'section' | 'row';
className?: string;
}
/** Thin diagonal stripes in the theme's own ink, so they read in light and dark alike. */
const STRIPES =
'repeating-linear-gradient(135deg, color-mix(in srgb, var(--text-primary) 8%, transparent) 0 9px, transparent 9px 18px)';
/**
* A section the shared demo account may look at but not change: dimmed,
* out of reach for pointer and keyboard, under a striped veil that carries a
* small "Demo mode" badge and the reason. Used on the desktop settings and on
* the phone screens alike.
*/
export default function DemoLock({ locked, reason, children, size = 'section', className = '' }: DemoLockProps) {
const { t } = useTranslation();
if (!locked) return <>{children}</>;
return (
<div className={`relative ${className}`} data-demo-lock="">
<div inert aria-hidden="true" className="pointer-events-none select-none opacity-40 saturate-50">
{children}
</div>
<div
role="note"
className={`absolute inset-0 flex justify-center rounded-2xl border border-dashed border-edge-secondary ${
size === 'row' ? 'items-center p-1' : 'items-start p-3'
}`}
style={{ background: STRIPES }}
>
<div className="sticky top-3 flex max-w-full flex-wrap items-center justify-center gap-x-2.5 gap-y-1 rounded-2xl bg-surface-card px-3 py-2 text-center shadow-md ring-1 ring-edge-faint">
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-2 py-0.5 font-semibold uppercase tracking-[.06em] text-accent-text"
style={fs(10)}
>
<Lock size={11} strokeWidth={2.4} aria-hidden />
{t('demo.lock.badge')}
</span>
<span className="min-w-0 text-content-secondary" style={fs(12.5, 'body')}>
{reason}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import { act, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import DemoWelcome from './DemoWelcome';
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { serverTimezone: 'UTC' });
});
afterEach(() => {
vi.useRealTimers();
});
describe('DemoWelcome', () => {
it('FE-COMP-DEMOWELCOME-001: opens as a dialog saying what TREK is', () => {
render(<DemoWelcome />);
const dialog = screen.getByRole('dialog', { name: 'Live demo' });
expect(dialog).toHaveTextContent('Welcome to TREK');
expect(dialog).toHaveTextContent('The travel planner you share');
});
it('FE-COMP-DEMOWELCOME-002: says the demo resets every hour and what it cannot do', () => {
render(<DemoWelcome />);
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
for (const line of [
'Uploading photos, documents and covers',
'Changing the account, its password or 2FA',
'Creating API keys',
'Connecting MCP clients',
'Connecting your own AI model',
])
expect(screen.getByText(line)).toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-003: shows the highlights to try and what the full version adds', () => {
render(<DemoWelcome />);
for (const title of [
'Trip planner',
'Tours and hikes',
'Photo books in TREK Studio',
'Plan together live',
'Journey travel journal',
'Atlas world map',
'Vacay holiday planner',
'Collections',
'Works offline',
])
expect(screen.getByText(title)).toBeInTheDocument();
for (const line of ['Sign in with OIDC and SSO', 'Automatic backups', 'Plugins for new features'])
expect(screen.getByText(line)).toBeInTheDocument();
expect(screen.getByText('The AI assistant on your own model')).toBeInTheDocument();
expect(screen.getByText('An admin panel for users, addons and permissions')).toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-004: links to the repository in a new tab', () => {
render(<DemoWelcome />);
const link = screen.getByRole('link', { name: 'Source code on GitHub' });
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-COMP-DEMOWELCOME-005: the countdown runs live in the server zone', async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
// On a whole second, so every tick of the interval lands on one too.
vi.setSystemTime(new Date(Date.UTC(2026, 9, 24, 12, 17, 53)));
render(<DemoWelcome />);
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:07');
// Advancing the fake timers moves the fake clock with them: the fifth tick reads 12:17:58.
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:02');
});
it('FE-COMP-DEMOWELCOME-006: the button closes it', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Start exploring' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-007: Escape closes it too', () => {
render(<DemoWelcome />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-008: the photo credits open in its place and the back button returns', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
const list = await screen.findByRole('list', { name: 'Photo credits' }, { timeout: 5000 });
expect(within(list).getAllByRole('listitem')).toHaveLength(228);
expect(screen.getByText(/come from Wikimedia Commons/)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Photo credits' })).toBeInTheDocument();
expect(screen.queryByText('Everything resets every hour')).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Live demo' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back' })).toHaveFocus();
await user.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
expect(screen.queryByRole('list', { name: 'Photo credits' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Photo credits' })).toHaveFocus();
});
it('FE-COMP-DEMOWELCOME-009: a photo names its title, author and license, each linking to its source', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
const title = await screen.findByRole('link', { name: 'Lagoa Rasa (Sete Cidades) 2' }, { timeout: 5000 });
expect(title).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg');
expect(title).toHaveAttribute('target', '_blank');
expect(title).toHaveAttribute('rel', 'noopener noreferrer');
const row = within(title.closest('li') as HTMLElement);
expect(row.getByText('by Walmaul')).toBeInTheDocument();
const license = row.getByRole('link', { name: 'CC BY-SA 4.0' });
expect(license).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
expect(license).toHaveAttribute('target', '_blank');
expect(license).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-COMP-DEMOWELCOME-010: a public domain photo names it without a deed, and a cleaned author reads as a name', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
const abiskoTitle = await screen.findByRole('link', { name: 'AbiskoNationalpark01' }, { timeout: 5000 });
const abisko = within(abiskoTitle.closest('li') as HTMLElement);
expect(abisko.getByText('Public domain')).toBeInTheDocument();
expect(abisko.getAllByRole('link')).toHaveLength(1);
const zinnen = within(
screen.getByRole('link', { name: 'Drei Zinnen Tre Cime di Lavaredo Dolomites' }).closest('li') as HTMLElement
);
expect(zinnen.getByText('by Wolfgang Moroder')).toBeInTheDocument();
expect(zinnen.getByRole('link', { name: 'CC BY-SA 3.0' })).toHaveAttribute(
'href',
'https://creativecommons.org/licenses/by-sa/3.0/'
);
});
});
+205
View File
@@ -0,0 +1,205 @@
import { ArrowLeft, ArrowRight, Clock, ExternalLink, Images, RotateCcw } from 'lucide-react';
import { lazy, Suspense, useId } from 'react';
import { useTranslation } from '../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
PILL,
} from '../shared/DialogShell';
import ErrorBoundary from '../shared/ErrorBoundary';
import TrekIcon from '../shared/TrekIcon';
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, TREK_REPO_URL, type DemoLine } from './demoWelcomeModel';
import { useDemoWelcome } from './useDemoWelcome';
// The credits and their photo list load when a visitor opens them, not with the dashboard.
const DemoCredits = lazy(() => import('./DemoCredits'));
type Translate = ReturnType<typeof useTranslation>['t'];
const FOOTER_LINK = 'inline-flex items-center gap-1.5 font-medium text-content-muted no-underline hover:text-content';
/** The tiles: what a visitor can open right now, one card each. */
function FeatureGrid({ t }: { t: Translate }) {
return (
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{DEMO_FEATURES.map(({ id, icon: Icon, titleKey, bodyKey }) => (
<div key={id} className="flex gap-3 rounded-[16px] border border-edge-faint bg-surface-secondary p-3.5">
<span className="grid h-9 w-9 flex-none place-items-center rounded-[12px] bg-surface-card text-content shadow-sm">
<Icon size={17} strokeWidth={2} aria-hidden />
</span>
<div className="min-w-0">
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
{t(titleKey)}
</div>
<p className="m-0 mt-0.5 leading-snug text-content-muted" style={fs(12, 'body')}>
{t(bodyKey)}
</p>
</div>
</div>
))}
</div>
);
}
/** A short list with an icon per line, for what the demo leaves out and what the full version adds. */
function LineList({ lines, tone, t }: { lines: readonly DemoLine[]; tone: 'muted' | 'accent'; t: Translate }) {
const iconClass = tone === 'accent' ? 'text-success' : 'text-content-faint';
return (
<ul className="m-0 flex list-none flex-col gap-2 rounded-[16px] border border-edge-faint bg-surface-card p-3.5">
{lines.map(({ icon: Icon, labelKey }) => (
<li key={labelKey} className="flex items-center gap-2.5 text-content-secondary" style={fs(12.5, 'body')}>
<Icon size={15} strokeWidth={2} className={`flex-none ${iconClass}`} aria-hidden />
<span>{t(labelKey)}</span>
</li>
))}
</ul>
);
}
/** The welcome itself: the hourly reset, the tiles, and the two lists under them. */
function WelcomeBody({ t }: { t: Translate }) {
return (
<>
<div className="flex items-start gap-3 rounded-[16px] bg-warning-soft px-4 py-3">
<RotateCcw size={17} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" aria-hidden />
<div className="min-w-0">
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
{t('demo.welcome.resetTitle')}
</div>
<p className="m-0 mt-0.5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
{t('demo.welcome.resetBody')}
</p>
</div>
</div>
<DialogSection label={t('demo.welcome.tryTitle')}>
<FeatureGrid t={t} />
</DialogSection>
<div className="grid gap-5 md:grid-cols-2">
<DialogSection label={t('demo.welcome.limitsTitle')}>
<LineList lines={DEMO_LIMITS} tone="muted" t={t} />
</DialogSection>
<DialogSection label={t('demo.welcome.fullTitle')}>
<LineList lines={DEMO_FULL_VERSION} tone="accent" t={t} />
</DialogSection>
</div>
</>
);
}
/**
* The footer's links: the source code, and the credits for the demo's photos.
* One above the other on a phone, where the footer has no room for both
* beside the button.
*/
function FooterLinks({ t, onCredits, focusCredits }: { t: Translate; onCredits: () => void; focusCredits: boolean }) {
return (
<div className="flex min-w-0 flex-col items-start gap-1.5 sm:flex-row sm:items-center sm:gap-4">
<a
href={TREK_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={FOOTER_LINK}
style={fs(12.5, 'body')}
>
<ExternalLink size={15} strokeWidth={2} aria-hidden />
{t('demo.welcome.repo')}
</a>
<button
type="button"
onClick={onCredits}
autoFocus={focusCredits}
className={FOOTER_LINK}
style={fs(12.5, 'body')}
>
<Images size={15} strokeWidth={2} aria-hidden />
{t('demo.welcome.credits')}
</button>
</div>
);
}
/**
* The welcome a demo instance opens with: what TREK is, that the demo resets
* on the hour (with the live countdown), what to try right away, what the
* demo leaves out and what the full version adds. The photo credits open in
* its place and back. Closed by the button, Escape or a click on the
* backdrop, and shown again on the next visit.
*/
export default function DemoWelcome() {
const { t } = useTranslation();
const labelId = useId();
const { open, close, countdown, creditsOpen, backFromCredits, openCredits, closeCredits } = useDemoWelcome();
return (
<DialogShell
open={open}
onClose={close}
labelledBy={labelId}
width="wide"
header={
<DialogHeader
tile={
<DialogTile>
<TrekIcon className="h-7 w-7 text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={close}
eyebrow={t('demo.welcome.eyebrow')}
title={t(creditsOpen ? 'demo.credits.title' : 'demo.welcome.title')}
sub={creditsOpen ? undefined : t('demo.welcome.tagline')}
subWraps
pills={
<span className={PILL}>
<Clock size={13} strokeWidth={2.2} aria-hidden />
{t('demo.welcome.resetIn')}
<span className="font-geist tabular-nums" data-testid="demo-countdown">
{countdown}
</span>
</span>
}
/>
}
footer={
<DialogFooter>
{/* The start button keeps its place in both views, so switching does not move the focus onto it again. */}
{creditsOpen ? (
<DialogButton
onClick={closeCredits}
autoFocus
icon={<ArrowLeft size={15} strokeWidth={2.2} className="rtl:-scale-x-100" />}
>
{t('demo.credits.back')}
</DialogButton>
) : (
<FooterLinks t={t} onCredits={openCredits} focusCredits={backFromCredits} />
)}
<FooterSpacer />
<DialogButton variant="primary" onClick={close} autoFocus icon={<ArrowRight size={15} strokeWidth={2.2} />}>
{t('demo.welcome.start')}
</DialogButton>
</DialogFooter>
}
>
{creditsOpen ? (
<ErrorBoundary boundaryId="demo-credits">
<Suspense fallback={null}>
<DemoCredits />
</Suspense>
</ErrorBoundary>
) : (
<WelcomeBody t={t} />
)}
</DialogShell>
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { PHOTO_CREDITS, PUBLIC_DOMAIN, commonsPage, creditTitle, licenseDeed } from './demoCreditsModel';
describe('demo photo credits', () => {
it('FE-DEMO-CREDITS-001: credits every photo of the demo content, each file once', () => {
expect(PHOTO_CREDITS).toHaveLength(228);
expect(new Set(PHOTO_CREDITS.map((c) => c.file)).size).toBe(228);
for (const { file } of PHOTO_CREDITS) expect(file, file).toMatch(/\.jpe?g$/i);
});
it('FE-DEMO-CREDITS-002: every author is a plain name, with no Commons notice or link label left', () => {
for (const { file, author } of PHOTO_CREDITS) {
expect(author.trim(), file).not.toBe('');
expect(author, file).toBe(author.trim());
expect(author.length, `${file}: ${author}`).toBeLessThanOrEqual(60);
expect(author, file).not.toMatch(/\s{2,}|\n/);
expect(author, file).not.toMatch(
/Photo was taken|machine-readable|assumed|Feel free|copyright|https?:|User:|\((?:Website|Flickr)\)/i
);
}
const authorOf = (file: string) => PHOTO_CREDITS.find((c) => c.file === file)?.author;
expect(authorOf('Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg')).toBe('Wolfgang Moroder');
expect(authorOf('Iceland Godafoss 1972.jpg')).toBe('Roger McLassus');
expect(authorOf('Arashiyama Bamboo Grove (Unsplash).jpg')).toBe('Erol Ahmed');
});
it('FE-DEMO-CREDITS-003: every license has its deed, except the public domain', () => {
for (const { file, license } of PHOTO_CREDITS) {
if (license === PUBLIC_DOMAIN) expect(licenseDeed(license), file).toBeUndefined();
else expect(licenseDeed(license), `${file}: ${license}`).toMatch(/^https:\/\/creativecommons\.org\/.+\/$/);
}
});
it('FE-DEMO-CREDITS-004: derives the deed from the license name', () => {
expect(licenseDeed('CC BY-SA 4.0')).toBe('https://creativecommons.org/licenses/by-sa/4.0/');
expect(licenseDeed('CC BY 2.5')).toBe('https://creativecommons.org/licenses/by/2.5/');
expect(licenseDeed('CC BY-SA 2.0 de')).toBe('https://creativecommons.org/licenses/by-sa/2.0/de/');
expect(licenseDeed('CC0')).toBe('https://creativecommons.org/publicdomain/zero/1.0/');
expect(licenseDeed(PUBLIC_DOMAIN)).toBeUndefined();
expect(licenseDeed('GFDL')).toBeUndefined();
});
it('FE-DEMO-CREDITS-005: shows the title without its extension and links the page on Commons', () => {
expect(creditTitle('Café A Brasileira. Sculpture.jpg')).toBe('Café A Brasileira. Sculpture');
expect(creditTitle('Svínafellsjökull.JPG')).toBe('Svínafellsjökull');
expect(creditTitle('No extension')).toBe('No extension');
expect(commonsPage('Lagoa Rasa (Sete Cidades) 2.jpg')).toBe(
'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg'
);
expect(commonsPage('Glacier du Géant & Mont Blanc.jpg')).toBe(
'https://commons.wikimedia.org/wiki/File:Glacier_du_G%C3%A9ant_%26_Mont_Blanc.jpg'
);
});
});
@@ -0,0 +1,50 @@
import credits from './photoCredits.json';
/**
* One photo of the demo content as Wikimedia Commons credits it: the file's
* name there, its author and the short name of its license. The page on
* Commons, the title to show and the license's deed all follow from these, so
* photoCredits.json holds nothing else.
*/
export interface PhotoCredit {
file: string;
author: string;
license: string;
}
/**
* Every photo in the demo content, sorted by file name. The authors are the
* plain names: where Commons fills the field with a notice or a link label
* ("This Photo was taken by ...", "(Flickr)"), the list holds the name it
* gives, and where the author asks to be credited in a set form, that form.
*/
export const PHOTO_CREDITS: readonly PhotoCredit[] = credits;
/** The license name Commons gives a photo nobody holds a copyright in; it has no deed to link. */
export const PUBLIC_DOMAIN = 'Public domain';
const COMMONS_FILE_PAGE = 'https://commons.wikimedia.org/wiki/File:';
/** The file's name without its extension, the title Commons shows above the photo. */
export function creditTitle(file: string): string {
const dot = file.lastIndexOf('.');
return dot > 0 ? file.slice(0, dot) : file;
}
/** The file's page on Commons, with its full description and license. */
export function commonsPage(file: string): string {
return COMMONS_FILE_PAGE + encodeURIComponent(file.replace(/ /g, '_'));
}
/**
* The deed of a Creative Commons license by the short name Commons gives it:
* "CC BY-SA 4.0", a ported one such as "CC BY-SA 2.0 de", or "CC0". Nothing
* for the public domain, which is no license.
*/
export function licenseDeed(license: string): string | undefined {
if (license === 'CC0') return 'https://creativecommons.org/publicdomain/zero/1.0/';
const match = /^CC (BY(?:-SA)?) (\d\.\d)(?: ([a-z]{2}))?$/.exec(license);
if (!match) return undefined;
const [, terms, version, port] = match;
return `https://creativecommons.org/licenses/${terms.toLowerCase()}/${version}/${port ? `${port}/` : ''}`;
}
@@ -0,0 +1,59 @@
import en from '@trek/shared/i18n/en';
import { describe, expect, it } from 'vitest';
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, formatCountdown, secondsUntilReset } from './demoWelcomeModel';
describe('demo welcome model', () => {
it('FE-DEMO-MODEL-001: every tile and line names a key the English catalogue holds', () => {
const keys = [
...DEMO_FEATURES.flatMap((f) => [f.titleKey, f.bodyKey]),
...DEMO_LIMITS.map((l) => l.labelKey),
...DEMO_FULL_VERSION.map((l) => l.labelKey),
];
for (const key of keys) expect(Object.keys(en), key).toContain(key);
});
it('FE-DEMO-MODEL-002: shows the highlights a visitor can open, each once', () => {
expect(DEMO_FEATURES.map((f) => f.id)).toEqual([
'planner',
'tours',
'studio',
'collab',
'journey',
'atlas',
'vacay',
'collections',
'offline',
]);
});
it('FE-DEMO-MODEL-003: names everything the demo account cannot do', () => {
expect(DEMO_LIMITS.map((l) => l.labelKey)).toEqual([
'demo.limit.uploads',
'demo.limit.account',
'demo.limit.apiKeys',
'demo.limit.mcp',
'demo.limit.ai',
]);
});
it('FE-DEMO-MODEL-004: counts down to the next full hour in the server zone', () => {
const at = new Date(Date.UTC(2026, 9, 24, 12, 17, 53));
expect(secondsUntilReset(at, 'UTC')).toBe(42 * 60 + 7);
// A zone half an hour off UTC has its hour mark at half past, UTC time.
expect(secondsUntilReset(at, 'Asia/Kolkata')).toBe(12 * 60 + 7);
});
it('FE-DEMO-MODEL-005: falls back to the browser clock for a zone it does not know, or none', () => {
const at = new Date(2026, 9, 24, 12, 59, 30);
expect(secondsUntilReset(at, 'Not/AZone')).toBe(30);
expect(secondsUntilReset(at)).toBe(30);
expect(secondsUntilReset(new Date(2026, 9, 24, 13, 0, 0))).toBe(3600);
});
it('FE-DEMO-MODEL-006: formats the countdown as minutes and seconds', () => {
expect(formatCountdown(42 * 60 + 7)).toBe('42:07');
expect(formatCountdown(5)).toBe('00:05');
expect(formatCountdown(3600)).toBe('60:00');
expect(formatCountdown(-3)).toBe('00:00');
});
});
@@ -0,0 +1,111 @@
import type { LucideIcon } from 'lucide-react';
import {
BookImage,
Bookmark,
Bot,
CalendarDays,
DatabaseBackup,
Globe,
KeyRound,
LayoutDashboard,
Map as MapIcon,
Mountain,
NotebookPen,
Puzzle,
ShieldCheck,
Sparkles,
Terminal,
Upload,
UserLock,
Users,
WifiOff,
} from 'lucide-react';
/** One thing a visitor can try in the demo right away: a tile of the welcome dialog. */
interface DemoFeature {
id: string;
icon: LucideIcon;
titleKey: string;
bodyKey: string;
}
/** A line of the two lists under the tiles: what the demo leaves out, and what the full version adds. */
export interface DemoLine {
icon: LucideIcon;
labelKey: string;
}
export const DEMO_FEATURES: readonly DemoFeature[] = [
{ id: 'planner', icon: MapIcon, titleKey: 'demo.feature.planner.title', bodyKey: 'demo.feature.planner.body' },
{ id: 'tours', icon: Mountain, titleKey: 'demo.feature.tours.title', bodyKey: 'demo.feature.tours.body' },
{ id: 'studio', icon: BookImage, titleKey: 'demo.feature.studio.title', bodyKey: 'demo.feature.studio.body' },
{ id: 'collab', icon: Users, titleKey: 'demo.feature.collab.title', bodyKey: 'demo.feature.collab.body' },
{ id: 'journey', icon: NotebookPen, titleKey: 'demo.feature.journey.title', bodyKey: 'demo.feature.journey.body' },
{ id: 'atlas', icon: Globe, titleKey: 'demo.feature.atlas.title', bodyKey: 'demo.feature.atlas.body' },
{ id: 'vacay', icon: CalendarDays, titleKey: 'demo.feature.vacay.title', bodyKey: 'demo.feature.vacay.body' },
{
id: 'collections',
icon: Bookmark,
titleKey: 'demo.feature.collections.title',
bodyKey: 'demo.feature.collections.body',
},
{ id: 'offline', icon: WifiOff, titleKey: 'demo.feature.offline.title', bodyKey: 'demo.feature.offline.body' },
];
/** What the shared demo account cannot do; the server refuses each of them too. */
export const DEMO_LIMITS: readonly DemoLine[] = [
{ icon: Upload, labelKey: 'demo.limit.uploads' },
{ icon: UserLock, labelKey: 'demo.limit.account' },
{ icon: KeyRound, labelKey: 'demo.limit.apiKeys' },
{ icon: Terminal, labelKey: 'demo.limit.mcp' },
{ icon: Sparkles, labelKey: 'demo.limit.ai' },
];
export const DEMO_FULL_VERSION: readonly DemoLine[] = [
{ icon: ShieldCheck, labelKey: 'demo.full.sso' },
{ icon: DatabaseBackup, labelKey: 'demo.full.backups' },
{ icon: Puzzle, labelKey: 'demo.full.plugins' },
{ icon: Bot, labelKey: 'demo.full.assistant' },
{ icon: LayoutDashboard, labelKey: 'demo.full.admin' },
];
export const TREK_REPO_URL = 'https://github.com/liketrek/TREK';
/** The demo resets on the hour (the server's `0 * * * *` cron). */
const HOUR_SECONDS = 3600;
/**
* Minute and second of the hour at `now` in the server's time zone, where the
* reset cron runs. A zone whose offset is not whole hours (India, Nepal) moves
* the hour mark; an unknown zone falls back to the browser's clock.
*/
function minuteAndSecond(now: Date, timeZone: string | undefined): [number, number] {
if (timeZone) {
try {
const parts = new Intl.DateTimeFormat('en-US', { timeZone, minute: 'numeric', second: 'numeric' }).formatToParts(
now
);
const read = (type: 'minute' | 'second') => Number(parts.find((p) => p.type === type)?.value);
const minute = read('minute');
const second = read('second');
if (Number.isFinite(minute) && Number.isFinite(second)) return [minute, second];
} catch {
// A zone this browser does not know: the local clock below.
}
}
return [now.getMinutes(), now.getSeconds()];
}
/** Seconds until the next hourly reset, from 1 up to 3600. */
export function secondsUntilReset(now: Date, timeZone?: string): number {
const [minute, second] = minuteAndSecond(now, timeZone);
return HOUR_SECONDS - (minute * 60 + second);
}
/** The countdown as minutes and seconds, `42:07`. */
export function formatCountdown(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safe / 60);
const rest = safe % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
@@ -0,0 +1,498 @@
[
{
"file": "(Venice) - Fondamente zattere - Casa Scarpa.jpg",
"author": "Didier Descouens",
"license": "CC BY-SA 4.0"
},
{
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 03.jpg",
"author": "Holger Uwe Schmitt",
"license": "CC BY-SA 4.0"
},
{
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 05.jpg",
"author": "Holger Uwe Schmitt",
"license": "CC BY-SA 4.0"
},
{
"file": "021 Wild smiling harbor seal at Jökulsárlón (Iceland) Photo by Giles Laurent.jpg",
"author": "Giles Laurent",
"license": "CC BY-SA 4.0"
},
{ "file": "03613 ISL Akureyri cruise vessel vapors V P.jpg", "author": "Virtual-Pano", "license": "CC BY-SA 4.0" },
{ "file": "20181110 Fushimi Inari shrine 3.jpg", "author": "Balon Greyjoy", "license": "CC0" },
{ "file": "20181110 Fushimi Inari shrine 4.jpg", "author": "Balon Greyjoy", "license": "CC0" },
{ "file": "20181110 Fushimi Inari shrine 6.jpg", "author": "Balon Greyjoy", "license": "CC0" },
{ "file": "2019-08-01 Zugspitze 7.jpg", "author": "Maksym Kozlenko", "license": "CC BY-SA 4.0" },
{
"file": "2022-05-28 13-21-39 Heinabergslón and Heinabergsjökull.jpg",
"author": "Hansueli Krapf",
"license": "CC BY-SA 4.0"
},
{ "file": "2022-05-28 13-26-26 Island - Hoffell 118.0.jpg", "author": "Hansueli Krapf", "license": "CC BY-SA 4.0" },
{
"file": "2022-05-28 13-50-35 Panorama of Heinabergsjökull.jpg",
"author": "Hansueli Krapf",
"license": "CC BY-SA 4.0"
},
{ "file": "2023-11-04 Lycian Way near Kaş.jpg", "author": "Alexkom000", "license": "CC BY 4.0" },
{ "file": "AbiskoNationalpark01.jpg", "author": "Unterrichter at English Wikipedia", "license": "Public domain" },
{ "file": "Almannagjá gorge at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
{ "file": "Almannagjá rift at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
{ "file": "Arashiyama Bamboo Grove (Unsplash).jpg", "author": "Erol Ahmed", "license": "CC0" },
{ "file": "Arashiyama Bamboo Grove.jpg", "author": "Mitchwandrew", "license": "CC BY 4.0" },
{ "file": "Arashiyama-Bamboo-Grove-Sunset.jpg", "author": "Bjørn Christian Tørrissen", "license": "CC BY-SA 4.0" },
{ "file": "ArashiyamaTogetsukyo Kyoto.jpg", "author": "Kentagon", "license": "CC BY-SA 4.0" },
{ "file": "Arnarstapi (port).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{ "file": "Arnarstapi 2.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
{ "file": "Arnarstapi 4.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
{
"file": "Averau from Fedare at Passo Giau, Belluno, Veneto, Italy, 2025 October.jpg",
"author": "Simo Räsänen",
"license": "CC BY-SA 4.0"
},
{ "file": "Bamboo Forest, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
{ "file": "Bamboo Grove, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
{ "file": "Basalt Arch near Arnarstapi.jpg", "author": "Remarksman", "license": "CC BY 2.0" },
{
"file": "Basilica di Santa Maria della Salute, Venice (37770336971).jpg",
"author": "Dimitris Kamaras from Athens, Greece",
"license": "CC BY 2.0"
},
{ "file": "Black Sand Beach Iceland, Reynisfjara Beach.jpg", "author": "miketnorton", "license": "CC BY 2.0" },
{ "file": "Bubbly Diamond on the Beach - Iceland.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
{
"file": "Burano Hausfassade Wäsche-20090315-RM-113017.jpg",
"author": "Reinhold Möller",
"license": "CC BY-SA 4.0"
},
{
"file": "Burano Hausfassade Wäsche-20090315-RM-113043.jpg",
"author": "Reinhold Möller",
"license": "CC BY-SA 4.0"
},
{ "file": "Burano Vendig Aug 2021 4.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
{ "file": "Café A Brasileira. Sculpture.jpg", "author": "MFREYNAUD51", "license": "CC BY-SA 4.0" },
{
"file": "Café de Flore, 172 boulevard Saint-Germain, Paris 6e.jpg",
"author": "Celette",
"license": "CC BY-SA 4.0"
},
{
"file": "Café Majestic - Porto - Portugal (53283088293).jpg",
"author": "Vitor Oliveira from Torres Vedras, PORTUGAL",
"license": "CC BY-SA 2.0"
},
{ "file": "Café Sperl, Wien.jpg", "author": "Jeremy Thompson", "license": "CC BY 2.0" },
{ "file": "Caffè San Marco-Trieste.jpg", "author": "Tiesse", "license": "Public domain" },
{ "file": "Caffè Sant'Eustachio.JPG", "author": "LPLT", "license": "CC BY-SA 3.0" },
{ "file": "Canale Grande in the morning light - panoramio.jpg", "author": "Schölla Schwarz", "license": "CC BY 3.0" },
{ "file": "Cinque Torri 01.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
{ "file": "Cinque Torri 02.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
{ "file": "Cinque Torri 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Cinque Torri, Dolomites - panoramio (1).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
{ "file": "Cinque Torri, Dolomites - panoramio (2).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
{
"file": "Colourful houses in Corte della Comare, Burano (detail).jpg",
"author": "Marco Ober",
"license": "CC BY-SA 4.0"
},
{ "file": "Croda del Becco and Rifugio Biella 202008.jpg", "author": "Marie-Lan Taÿ Pamart", "license": "CC BY 4.0" },
{
"file": "Cuernos del Paine in Torres del Paine National Park.jpg",
"author": "Pedro Szekely from Los Angeles, USA",
"license": "CC BY-SA 2.0"
},
{ "file": "Dettifoss 2026 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Dettifoss Panorama 01.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
{ "file": "Dettifoss Panorama 02.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
{ "file": "Dettifoss Panorama 03.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
{ "file": "Dorsoduro Rio dei Carmini in Venice.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Dotonbori Osaka Japan01-r.jpg", "author": "Oilstreet", "license": "CC BY 2.5" },
{ "file": "Dotonbori, Osaka, at night, November 2016.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Dyrholaey Arch.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
{
"file": "Dyrhólaey, seen from Reynisfjara, Iceland, 20240720 0831 2824.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{ "file": "Eibsee und Ammergauer Alpen.jpg", "author": "Octagon", "license": "CC BY 3.0" },
{
"file": "Ëur de Bredles Secëda Odles y Sas Rigais da Resciesa.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 4.0"
},
{ "file": "Faloria Cortina d'Ampezzo 10.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Falzarego-N.jpg", "author": "Supakon Mohr", "license": "CC BY-SA 3.0 de" },
{ "file": "Fimmvörðuháls (1).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{ "file": "Fimmvörðuháls (9).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{
"file": "Fimmvorduhals-emergency-hut.jpg",
"author": "Reen Eversdijk from Groningen, The Netherlands",
"license": "CC BY 2.0"
},
{ "file": "Fjaðrárgljúfur 04.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Forcella Nuvolau–Nuvolau lodge trail.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
{
"file": "Four ladies wearing a yukata in front of the North Gate of Kiyomizu-dera temple Kyoto Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{
"file": "Frea pass Sella group Pisciadu Dolomites South Tyrol.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 3.0"
},
{ "file": "Funivia del Lagazuoi (passo Falzarego).jpg", "author": "Ale041VE", "license": "CC BY-SA 4.0" },
{ "file": "Gion Hanamikôji-dôri.jpg", "author": "Yanajin33", "license": "CC BY-SA 4.0" },
{
"file": "Glacier du Mont Mallet (Glacier du Géant) & Mont Blanc, 2010 July.jpg",
"author": "Ximonic, Simo Räsänen",
"license": "CC BY-SA 3.0"
},
{ "file": "Godafoss 2.jpg", "author": "Tord Dellsen", "license": "CC0" },
{ "file": "Godafoss 3.jpg", "author": "Tord Dellsen", "license": "CC0" },
{ "file": "Godafoss 4.jpg", "author": "Tord Dellsen", "license": "CC0" },
{ "file": "Goðafoss Island.jpg", "author": "Superbass", "license": "CC BY-SA 3.0" },
{
"file": "Gondola Guido Lorenzi - Son Forca Cristallo Italy - panoramio.jpg",
"author": "Hiroki Ogawa",
"license": "CC BY 3.0"
},
{ "file": "GR20 Corsica Montains-2.jpg", "author": "Hermes from mars", "license": "CC BY-SA 3.0" },
{
"file": "Grand Canal Santa Maria della Salute Vaporetto Gondola Venice 01.jpg",
"author": "Julian Herzog",
"license": "CC BY 4.0"
},
{
"file": "Gullfoss, Suðurland, Islandia, 2014-08-16, DD 119.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Hallgrímskirkja 1.jpg", "author": "Dmitry Brant", "license": "CC BY-SA 4.0" },
{ "file": "Heinabergsjökull (38322450122).jpg", "author": "Markus Trienke", "license": "CC BY-SA 2.0" },
{ "file": "Hverfjall July 2014.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Hverir Námaskarð.jpg", "author": "JavierOlivares", "license": "CC BY-SA 4.0" },
{ "file": "Iceland Dettifoss 1972-4.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
{ "file": "Iceland Godafoss 1972.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
{ "file": "Il Sentiero degli dei.jpg", "author": "Stiflele", "license": "CC BY-SA 4.0" },
{
"file": "Illusion, Burano Island. (39893205060).jpg",
"author": "Mihai Lucîț from Baia Mare, Romania",
"license": "CC BY 2.0"
},
{ "file": "Islanda- Þórsmörk.jpg", "author": "Mario Falcetti", "license": "CC BY 4.0" },
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0935 2448.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0936 2452.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0937 2454.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0947 2477.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Kansai Airport (2660200665).jpg",
"author": "Kentaro Iemoto from Tokyo, Japan",
"license": "CC BY-SA 2.0"
},
{ "file": "Kinkaku-ji in November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{
"file": "Kinkaku-ji the Golden Temple in Kyoto overlooking the lake - high rez.JPG",
"author": "Jaycangel",
"license": "CC BY-SA 3.0"
},
{ "file": "Kirkjufell in Fog (Unsplash).jpg", "author": "Nicholas Kampouris", "license": "CC0" },
{ "file": "Kirkjufell with its reflection.jpg", "author": "_大面", "license": "CC BY 2.5" },
{ "file": "Kirkjufell, Iceland (Unsplash LoKG7kKx7Zc).jpg", "author": "Steven Diaz", "license": "CC0" },
{
"file": "Kirkjufell, Vesturland, Islandia, 2014-08-14, DD 091.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -01.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -03.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -06.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -07.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "KyotoFushimiInariLarge.jpg", "author": "Paul Vlaar", "license": "CC BY-SA 3.0" },
{ "file": "Lagazuoi 05.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
{ "file": "Lago alla base di Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Lago alla base di Svínafellsjökull 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 3.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 5.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 6.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 7.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 9.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{
"file": "Lago di Limo, Parco Naturale Fanes - Sennes - Braies (BZ).jpg",
"author": "53cri021",
"license": "CC BY-SA 4.0"
},
{ "file": "Lagoa Rasa (Sete Cidades) 2.jpg", "author": "Walmaul", "license": "CC BY-SA 4.0" },
{ "file": "Lake Ashi & Mt Fuji & Hakone Shrine.jpg", "author": "Charlie fong", "license": "CC BY-SA 4.0" },
{ "file": "Landmannalaugar in summer 2009 (13).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{
"file": "Manarola NW Cemetery Corniglia Monterosso Cinque Terre Sep23 A7C 06872.jpg",
"author": "Timothy A. Gonsalves",
"license": "CC BY-SA 4.0"
},
{ "file": "Meiji Jingu Shrine Tokyo Japan.jpg", "author": "MediaByPanda", "license": "CC BY-SA 4.0" },
{ "file": "Meiji shrine in August 2007 5.jpg", "author": "Syced", "license": "CC0" },
{ "file": "Meiji Shrine, Tokyo; April 2009 (02).jpg", "author": "MIKI Yoshihito", "license": "CC BY 2.0" },
{
"file": "Meiji-jingū main hall vue centrale.jpg",
"author": "Hyppolyte de Saint-Rambert",
"license": "CC BY-SA 4.0"
},
{ "file": "Meiji-jingu-pathway.jpg", "author": "Nightcrafter", "license": "CC BY-SA 4.0" },
{ "file": "Monte Kirkjufell (Luglio 2024).jpg", "author": "Paolova97", "license": "CC BY-SA 4.0" },
{ "file": "Morning light in Venice (33726527456).jpg", "author": "Hervé Simon", "license": "CC BY-SA 2.0" },
{ "file": "Mount Fuji and Shinkansen N700.jpg", "author": "Alpsdake", "license": "CC BY-SA 4.0" },
{
"file": "Mount Myojingatake and Owakudani Station from Owakudani Valley.JPG",
"author": "そらみみ",
"license": "CC BY-SA 3.0"
},
{ "file": "Mount-Fuji from Tokaido Shinkansen window view.jpg", "author": "Indiana jo", "license": "CC BY-SA 4.0" },
{
"file": "Mountain and grotto at Reynisfjara Beach in July, 2019.jpg",
"author": "Alexander Grebenkov",
"license": "CC BY 3.0"
},
{ "file": "Múlafossur2.jpg", "author": "Ekrem Canli", "license": "CC BY-SA 4.0" },
{ "file": "Neon Signs in Dotonbori, Osaka 20190415 1.jpg", "author": "DXR", "license": "CC BY-SA 4.0" },
{
"file": "Neunerspitze, 2968 m und Zehnerspitze 3016 m.jpg",
"author": "2015 Michael 2015",
"license": "CC BY-SA 4.0"
},
{ "file": "NeunerZehnerFanes.JPG", "author": "Alex1011", "license": "CC BY 2.5" },
{ "file": "Nishiki ichiba kyoto.jpg", "author": "Pitan", "license": "CC BY-SA 3.0" },
{ "file": "Old Tokaido - Hakone.jpg", "author": "Quercus acuta", "license": "CC BY-SA 4.0" },
{ "file": "Old Tokaido -01.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "On the hillside above Ginkaku-ji (2099332601).jpg", "author": "KimonBerlin", "license": "CC BY-SA 2.0" },
{ "file": "Osaka Dotonbori Canal.jpg", "author": "32linesky", "license": "CC BY-SA 4.0" },
{
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 1.JPG",
"author": "そらみみ",
"license": "CC BY-SA 3.0"
},
{
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 2.JPG",
"author": "そらみみ",
"license": "CC BY-SA 3.0"
},
{ "file": "Owakudani Valley near Owakudani Station 2.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
{ "file": "Owakudani Valley near Owakudani Station.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
{ "file": "Palazzo Balbi (Venice).jpg", "author": "Didier Descouens", "license": "CC BY-SA 3.0" },
{
"file": "Palazzo dei Camerlenghi e Ponte Rialto a Venezia.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 3.0"
},
{
"file": "Panorama of Canal Grande and Ponte di Rialto, Venice - September 2017.jpg",
"author": "Martin Falbisoner",
"license": "CC BY-SA 4.0"
},
{ "file": "Passo di Giau.jpg", "author": "Frisia Orientalis", "license": "CC BY-SA 3.0" },
{ "file": "Passo Giau SP638.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Photos at teamlab planets tokyo.jpg", "author": "Sasa0403", "license": "CC BY-SA 4.0" },
{ "file": "Ponte di Rialto facciata ovest di sera.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Ponte di Rialto Venice 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Pontile Zattere (Venezia).jpg", "author": "Didier Descouens", "license": "CC BY-SA 4.0" },
{ "file": "Preikestolen Norge.jpg", "author": "Stefan Krause, Germany", "license": "CC BY-SA 3.0" },
{ "file": "Ra Gusela, view from Passo Giau.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
{ "file": "Reine i Lofoten LC0148.jpg", "author": "Jörg Hempel", "license": "CC BY-SA 2.0 de" },
{
"file": "Reynisfjara Black Sand Beach Iceland Travel Photography (210387065).jpeg",
"author": "Giuseppe Milo",
"license": "CC BY 3.0"
},
{
"file": "Reynisfjara, Suðurland, Islandia, 2014-08-17, DD 164.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{
"file": "Reyniskirkja Church,Vík í Mýrdal, Suðurland,Ísland.jpg",
"author": "JavierOlivares",
"license": "CC BY-SA 4.0"
},
{ "file": "Rifugio Fanes 01.JPG", "author": "Bbruno", "license": "CC BY-SA 3.0" },
{ "file": "Rifugio Fanes.jpg", "author": "Llorenzi", "license": "CC BY-SA 3.0" },
{ "file": "Rigi Kulm cross 20210905.jpg", "author": "Daniel Kraft", "license": "CC BY-SA 3.0" },
{
"file": "Roca de la Ley, Parque Nacional de Þingvellir, Suðurland, Islandia, 2014-08-16, DD 022.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Salzwelten vantage point.jpg", "author": "Grandmaster Huon", "license": "CC0" },
{ "file": "San Marco cathedral in Venice.JPG", "author": "Petar Milosevic", "license": "CC BY-SA 4.0" },
{
"file": "Santa Maria della Salute from Hotel Monaco nightview.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 3.0"
},
{ "file": "Santa Maria della Salute from Hotel Monaco.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Seljalandsfoss - panoramio (7).jpg", "author": "Zoran Kurelić Rabko", "license": "CC BY-SA 3.0" },
{ "file": "Seljalandsfoss and Gljúfrabúi, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
{ "file": "Seljalandsfoss July 2014 -1.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Seljalandsfoss view from side, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
{
"file": "Seljalandsfoss, Suðurland, Islandia, 2014-08-16, DD 189-191 HDR.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{
"file": "Senso-ji Temple @ Asakusa (13824517393).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Senso-ji Temple @ Asakusa (13824525803).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Senso-ji Temple @ Asakusa (13824839284).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Senso-ji Temple @ Asakusa (14019477806).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{ "file": "Seyðisfjörður church 1.jpg", "author": "Saifunny", "license": "CC BY-SA 4.0" },
{ "file": "Seyðisfjörður Sept 2019 1.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
{
"file": "Seydisfjordur, East Iceland (51440969579).jpg",
"author": "Fabio Achilli from Milano, Italy",
"license": "CC BY 2.0"
},
{
"file": "Seydisfjordur, East Iceland (51441184230).jpg",
"author": "Fabio Achilli from Milano, Italy",
"license": "CC BY 2.0"
},
{ "file": "Seyðisfjörður, Iceland (28328269217).jpg", "author": "Joe deSousa", "license": "CC0" },
{
"file": "Shadows and windows at Hallgrimskirkja Reykjavik Iceland 2026-02-07 21-21-01 1.jpg",
"author": "G. Edward Johnson",
"license": "CC BY 4.0"
},
{
"file": "Shariden, Kinkaku-ji, Kyoto, East View 20130811 1.jpg",
"author": "DXR / Daniel Vorndran",
"license": "CC BY-SA 3.0"
},
{ "file": "Shinjuku-West Omoide-Yokocho.jpg", "author": "MaedaAkihiko", "license": "CC BY-SA 4.0" },
{
"file": "Shinkansen N700S series photographed at Higashi-Tagonoura with Mount Fuji in the background, 10 April 2023.jpg",
"author": "Takeshi Aida",
"license": "CC BY-SA 2.0"
},
{ "file": "Skogafoss-02-2018-gje.jpg", "author": "Gerd Eichmann", "license": "CC BY-SA 4.0" },
{ "file": "Sky Lagoon Cold Plunge.jpg", "author": "Laurenmcl", "license": "CC BY-SA 4.0" },
{ "file": "Soca River Valley, Slovenia.jpg", "author": "Barry", "license": "CC BY 2.0" },
{
"file": "Start of the Old Hakone Highway (9412055176).jpg",
"author": "Maarten Heerlien from Voorschoten, The Netherlands",
"license": "CC BY 2.0"
},
{
"file": "Strokkur, Área geotérmica de Geysir, Suðurland, Islandia, 2014-08-16, DD 086.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Svínafellsjökull glacier at sunset.jpg", "author": "Mikołaj Bańka", "license": "CC BY 4.0" },
{ "file": "Svínafellsjökull.JPG", "author": "Bromr at Dutch Wikipedia", "license": "CC BY-SA 3.0" },
{
"file": "Taroko-Gorge Hualien Taiwan Pudu-Bridge-at-Taroko-National-Park-01.jpg",
"author": "CEphoto, Uwe Aranas",
"license": "CC BY-SA 3.0"
},
{
"file": "The Lava Fields Of Þórsmörk Iceland (175491845).jpeg",
"author": "Ilya Grigorik",
"license": "CC BY-SA 3.0"
},
{ "file": "The park bench (2669222928).jpg", "author": "Rosino", "license": "CC BY-SA 2.0" },
{
"file": "The West Highland Way approaching the Heads of Glen Etive and Glen Coe - geograph.org.uk - 5014659.jpg",
"author": "Tim Heaton",
"license": "CC BY-SA 2.0"
},
{ "file": "Tokyo Shibuya Scramble Crossing 2018-10-09.jpg", "author": "Benh LIEU SONG", "license": "CC BY-SA 2.0" },
{
"file": "Torii on Lake Ashi @ Hakone Shrine (13776878594).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Torii on Lake Ashi @ Hakone Shrine (13776879364).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Torii path with lantern at Fushimi Inari Taisha Shrine, Kyoto, Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{ "file": "Trolltunga 2.jpg", "author": "Steinar Talmoen", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market -04.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market -05.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market -06.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market 2.jpg", "author": "Aw1805", "license": "CC BY-SA 3.0" },
{ "file": "Ushguli Svaneti Georgia.JPG", "author": "Florian Pinel", "license": "CC BY-SA 3.0" },
{ "file": "Vaporetto grand canal venice.jpg", "author": "John Salatas", "license": "CC BY-SA 4.0" },
{ "file": "Vaporetto, Grand Canal, Venice.jpg", "author": "Peter K Burian", "license": "CC BY-SA 4.0" },
{ "file": "Venice, Canal Grande, Dorsoduro, Plzo Balbi.jpg", "author": "Armin Kleiner", "license": "CC BY-SA 4.0" },
{ "file": "Venice, Italy. Morning light.jpg", "author": "Dupont66", "license": "CC BY 4.0" },
{ "file": "Via ferrata Ivano Dibona - panoramio (1).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
{ "file": "Via ferrata Ivano Dibona - panoramio (2).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
{ "file": "Via Ferrato Ivano Dibona 3.jpg", "author": "Schi11", "license": "CC BY-SA 4.0" },
{ "file": "View from Miradouro do Pico do Arieiro - Madeira 05.jpg", "author": "H. Zell", "license": "CC BY-SA 3.0" },
{ "file": "Vire via ferrata Ivano Dibona.jpg", "author": "Evelyne Chassagneux-Bonini", "license": "CC BY-SA 4.0" },
{
"file": "Water reflection of Kinkaku-ji Temple a sunny day, Kyoto, Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{
"file": "Water reflection of Kinkaku-ji Temple with blue sky and white clouds, Kyoto, Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{ "file": "Water transport in Venice at Rialto bridge.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Waterfalls of Iceland 23 02.jpg", "author": "Hornstrandir1", "license": "CC BY-SA 4.0" },
{ "file": "Wengental 2019-10-18.jpg", "author": "2015 Michael 2015", "license": "CC BY-SA 4.0" },
{ "file": "Wien, Café Central -- 2018 -- 3135.jpg", "author": "Dietmar Rabich", "license": "CC BY-SA 4.0" },
{ "file": "Yanaka Ginza 2.jpg", "author": "Christophe95", "license": "CC BY-SA 4.0" },
{
"file": "Yanaka Ginza shopping street (4041690725).jpg",
"author": "Yoshikazu TAKADA from Tokyo, Japan",
"license": "CC BY 2.0"
},
{ "file": "Yanaka ginza street.jpg", "author": "Kentin", "license": "CC BY-SA 3.0" },
{ "file": "Yanaka Ginza, Tokyo, Japan (6790100548).jpg", "author": "yeowatzup", "license": "CC BY 2.0" },
{ "file": "Þingvellir, Almannagjá 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Þingvellir, Almannagjá 07.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "関西国際空港全体写真20220811.jpg", "author": "Ankou1192", "license": "CC BY-SA 4.0" }
]
@@ -0,0 +1,35 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useDemoAccountLock } from './useDemoLock';
beforeEach(() => resetAllStores());
describe('useDemoAccountLock', () => {
it('FE-HOOK-DEMOLOCK-001: locks the demo account on a demo instance', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
});
it('FE-HOOK-DEMOLOCK-002: knows the address the demo account had before the rename', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@nomad.app' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
});
it('FE-HOOK-DEMOLOCK-003: leaves the admin of a demo instance alone', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
});
it('FE-HOOK-DEMOLOCK-004: leaves the demo address alone on an instance that is not a demo', () => {
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
});
it('FE-HOOK-DEMOLOCK-005: is off while nobody is signed in', () => {
seedStore(useAuthStore, { demoMode: true, user: null });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { isDemoAccountEmail } from '@trek/shared';
import { useAuthStore } from '../../store/authStore';
/**
* Whether the account signed in here is the shared demo account of a demo
* instance. Every visitor signs in as that one account, so its Account tab,
* API keys, MCP configuration, own AI connection and language sit under a
* DemoLock; the server refuses the same writes (`demo-write.ts`). The admin
* of a demo instance, and every account of a real one, see the sections as
* they are.
*/
export function useDemoAccountLock(): boolean {
const demoMode = useAuthStore((s) => s.demoMode);
const email = useAuthStore((s) => s.user?.email);
return demoMode && isDemoAccountEmail(email);
}
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { useAuthStore } from '../../store/authStore';
import { formatCountdown, secondsUntilReset } from './demoWelcomeModel';
/**
* Whether the photo credits have the dialog: not yet, now, or no longer
* (the visitor came back from them, and the focus returns to their link).
*/
type CreditsView = 'unseen' | 'open' | 'left';
/**
* The welcome dialog's state: whether it is still open, whether it shows the
* photo credits in place of the welcome, and the live countdown to the next
* hourly reset, ticking once a second in the server's time zone.
*/
export function useDemoWelcome() {
const timeZone = useAuthStore((s) => s.serverTimezone);
const [open, setOpen] = useState(true);
const [credits, setCredits] = useState<CreditsView>('unseen');
const [secondsLeft, setSecondsLeft] = useState(() => secondsUntilReset(new Date(), timeZone));
useEffect(() => {
if (!open) return;
const timer = setInterval(() => setSecondsLeft(secondsUntilReset(new Date(), timeZone)), 1000);
return () => clearInterval(timer);
}, [open, timeZone]);
return {
open,
close: () => setOpen(false),
countdown: formatCountdown(secondsLeft),
creditsOpen: credits === 'open',
backFromCredits: credits === 'left',
openCredits: () => setCredits('open'),
closeCredits: () => setCredits('left'),
};
}
@@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
import type { JourneyMapGLHandle } from './JourneyMapGL'
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
import { useGlRenderer } from '../Map/glSupport'
import type { JourneyTrack } from '@trek/shared'
import type { MapPhoto } from './journeyPhotoLayer'
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
@@ -56,10 +57,11 @@ function JourneyMapAuto({ ref, ...props }: Props) {
const leafletRef = useRef<JourneyMapHandle>(null)
const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
// Fall back to Leaflet when the user selected Mapbox GL but hasn't supplied a
// token yet (MapLibre/OpenFreeMap is tokenless), or when the browser has no WebGL2.
const glRenderer = useGlRenderer(provider, token)
const useGL = glRenderer !== null
const glProvider = glRenderer ?? 'mapbox-gl'
useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -107,7 +107,8 @@ const glModule = vi.hoisted(() => () => ({
}))
vi.mock('mapbox-gl', glModule)
vi.mock('maplibre-gl', glModule)
// maplibre-gl 6 has no default export: the same fakes, as named exports.
vi.mock('maplibre-gl', () => glModule().default)
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
@@ -121,7 +122,7 @@ vi.mock('../Map/mapboxSetup', () => ({
import React from 'react'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import * as maplibregl from 'maplibre-gl'
import { render, screen, act } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
@@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
@@ -360,11 +361,13 @@ function JourneyMapGL(
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: JourneyMapAuto has switched to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
marksFramedRef.current = false
entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])]
@@ -1,116 +0,0 @@
import { act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import DemoBanner from './DemoBanner';
describe('DemoBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
// FE-COMP-DEMOBANNER-001
it('renders without crashing', () => {
render(<DemoBanner />);
expect(document.body).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-002
it('overlay is visible on initial render with dismiss button', () => {
render(<DemoBanner />);
expect(screen.getByText('Got it')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-003
it('shows English welcome title by default', () => {
render(<DemoBanner />);
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-004
it('clicking "Got it" dismisses the banner', async () => {
const user = userEvent.setup();
render(<DemoBanner />);
const button = screen.getByText('Got it');
await user.click(button);
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-005
it('clicking the overlay backdrop dismisses the banner', () => {
const { container } = render(<DemoBanner />);
// The outermost fixed div is the overlay backdrop
const overlay = container.firstChild as HTMLElement;
fireEvent.click(overlay);
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-006
it('clicking the inner card does NOT dismiss', async () => {
const user = userEvent.setup();
render(<DemoBanner />);
// The inner card is the direct parent of the "Got it" button's container
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
await user.click(card);
expect(screen.getByText('Got it')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-007
it('shows reset timer', () => {
render(<DemoBanner />);
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-008
it('shows upload-disabled notice', () => {
render(<DemoBanner />);
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-009
it('shows "What is TREK?" section', () => {
render(<DemoBanner />);
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-010
it('shows addon cards', () => {
render(<DemoBanner />);
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-011
it('shows full version features section', () => {
render(<DemoBanner />);
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-012
it('self-host link points to GitHub', () => {
render(<DemoBanner />);
const link = screen.getByText('self-host it').closest('a')!;
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
expect(link).toHaveAttribute('target', '_blank');
});
// Timer update test
it('updates countdown timer after interval tick', async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
render(<DemoBanner />);
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
await act(async () => {
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
vi.advanceTimersByTime(10000);
});
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
});
});
-613
View File
@@ -1,613 +0,0 @@
import {
ArrowRightLeft,
CalendarDays,
Clock,
Database,
FileText,
Github,
Globe,
Key,
ListChecks,
Map,
Puzzle,
Shield,
Upload,
Users,
Wallet,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
interface DemoTexts {
titleBefore: string;
titleAfter: string;
title: string;
description: string;
resetIn: string;
minutes: string;
uploadNote: string;
fullVersionTitle: string;
features: string[];
addonsTitle: string;
addons: [string, string][];
whatIs: string;
whatIsDesc: string;
selfHost: string;
selfHostLink: string;
close: string;
}
const texts: Record<string, DemoTexts> = {
de: {
titleBefore: 'Willkommen bei ',
titleAfter: '',
title: 'Willkommen zur TREK Demo',
description:
'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
resetIn: 'Naechster Reset in',
minutes: 'Minuten',
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
fullVersionTitle: 'In der Vollversion zusaetzlich:',
features: [
'Datei-Uploads (Fotos, Dokumente, Cover)',
'API-Schluessel (Google Maps, Wetter)',
'Benutzer- & Rechteverwaltung',
'Automatische Backups',
'Addon-Verwaltung (aktivieren/deaktivieren)',
'OIDC / SSO Single Sign-On',
],
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
addons: [
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
['Packliste', 'Checklisten pro Reise'],
['Budget', 'Kostenplanung mit Splitting'],
['Dokumente', 'Dateien an Reisen anhaengen'],
['Widgets', 'Waehrungsrechner & Zeitzonen'],
],
whatIs: 'Was ist TREK?',
whatIsDesc:
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten',
close: 'Verstanden',
},
en: {
titleBefore: 'Welcome to ',
titleAfter: '',
title: 'Welcome to the TREK Demo',
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
resetIn: 'Next reset in',
minutes: 'minutes',
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
fullVersionTitle: 'Additionally in the full version:',
features: [
'File uploads (photos, documents, covers)',
'API key management (Google Maps, Weather)',
'User & permission management',
'Automatic backups',
'Addon management (enable/disable)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Modular Addons (can be deactivated in full version)',
addons: [
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
['Atlas', 'World map with visited countries & travel stats'],
['Packing', 'Checklists per trip'],
['Budget', 'Expense tracking with splitting'],
['Documents', 'Attach files to trips'],
['Widgets', 'Currency converter & timezones'],
],
whatIs: 'What is TREK?',
whatIsDesc:
'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
selfHost: 'Open source — ',
selfHostLink: 'self-host it',
close: 'Got it',
},
et: {
titleBefore: 'Tere tulemast ',
titleAfter: 'i',
title: 'Tere tulemast TREKi demosse',
description: 'Saad reise vaadata, muuta ja luua. Kõik muudatused lähtestatakse automaatselt iga tunni järel.',
resetIn: 'Järgmise lähtestamiseni',
minutes: 'minutit',
uploadNote: 'Failide (fotode, dokumentide ja kaanepiltide) üleslaadimine on demorežiimis keelatud.',
fullVersionTitle: 'Täisversioonis lisaks:',
features: [
'Failide üleslaadimine (fotod, dokumendid, kaanepildid)',
'API-võtmete haldus (Google Maps, ilm)',
'Kasutajate ja õiguste haldus',
'Automaatsed varukoopiad',
'Lisamoodulite haldus (lubamine/keelamine)',
'OIDC / SSO ühekordne sisselogimine',
],
addonsTitle: 'Lisamoodulid (täisversioonis saab välja lülitada)',
addons: [
['Vacay', 'Puhkuseplaneerija kalendri, pühade ja kasutajate plaanide ühendamisega'],
['Atlas', 'Maailmakaart külastatud riikide ja reisistatistikaga'],
['Pakkimine', 'Iga reisi kontrollnimekirjad'],
['Eelarve', 'Kulude jälgimine ja jagamine'],
['Dokumendid', 'Lisa reisidele faile'],
['Vidinad', 'Valuutakalkulaator ja ajavööndid'],
],
whatIs: 'Mis on TREK?',
whatIsDesc:
'Ise majutatav reisiplaneerija reaalajas koostöö, interaktiivsete kaartide, OIDC sisselogimise ja tumeda režiimiga.',
selfHost: 'Avatud lähtekood — ',
selfHostLink: 'majuta ise',
close: 'Selge',
},
es: {
titleBefore: 'Bienvenido a ',
titleAfter: '',
title: 'Bienvenido a la demo de TREK',
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
resetIn: 'Próximo reinicio en',
minutes: 'minutos',
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
fullVersionTitle: 'Además, en la versión completa:',
features: [
'Subida de archivos (fotos, documentos, portadas)',
'Gestión de claves API (Google Maps, tiempo)',
'Gestión de usuarios y permisos',
'Copias de seguridad automáticas',
'Gestión de addons (activar/desactivar)',
'Inicio de sesión único OIDC / SSO',
],
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
addons: [
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
['Equipaje', 'Listas de comprobación para cada viaje'],
['Presupuesto', 'Control de gastos con reparto'],
['Documentos', 'Adjunta archivos a los viajes'],
['Widgets', 'Conversor de divisas y zonas horarias'],
],
whatIs: '¿Qué es TREK?',
whatIsDesc:
'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
selfHost: 'Código abierto — ',
selfHostLink: 'alójalo tú mismo',
close: 'Entendido',
},
zh: {
titleBefore: '欢迎来到 ',
titleAfter: '',
title: '欢迎来到 TREK 演示版',
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
resetIn: '下次重置将在',
minutes: '分钟后',
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
fullVersionTitle: '完整版本还包括:',
features: [
'文件上传(照片、文档、封面)',
'API 密钥管理(Google Maps、天气)',
'用户和权限管理',
'自动备份',
'附加组件管理(启用/禁用)',
'OIDC / SSO 单点登录',
],
addonsTitle: '模块化附加组件(完整版本可禁用)',
addons: [
['Vacay', '带日历、节假日和用户融合的假期规划器'],
['Atlas', '带已访问国家和旅行统计的世界地图'],
['Packing', '按旅行管理清单'],
['Budget', '支持分摊的费用追踪'],
['Documents', '将文件附加到旅行'],
['Widgets', '货币换算和时区工具'],
],
whatIs: '什么是 TREK?',
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
selfHost: '开源项目 - ',
selfHostLink: '自行部署',
close: '知道了',
},
'zh-TW': {
titleBefore: '歡迎來到 ',
titleAfter: '',
title: '歡迎來到 TREK 展示版',
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
resetIn: '下次重設將在',
minutes: '分鐘後',
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
fullVersionTitle: '完整版本還包含:',
features: [
'檔案上傳(照片、文件、封面)',
'API 金鑰管理(Google Maps、天氣)',
'使用者與權限管理',
'自動備份',
'附加元件管理(啟用/停用)',
'OIDC / SSO 單一登入',
],
addonsTitle: '模組化附加元件(完整版本可停用)',
addons: [
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
['Packing', '依行程管理的檢查清單'],
['Budget', '支援分攤的費用追蹤'],
['Documents', '將檔案附加到行程'],
['Widgets', '貨幣換算與時區工具'],
],
whatIs: 'TREK 是什麼?',
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
selfHost: '開源專案 - ',
selfHostLink: '自行架設',
close: '知道了',
},
ar: {
titleBefore: 'مرحبًا بك في ',
titleAfter: '',
title: 'مرحبًا بك في النسخة التجريبية من TREK',
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
resetIn: 'إعادة الضبط التالية خلال',
minutes: 'دقيقة',
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
features: [
'رفع الملفات (الصور والمستندات وصور الغلاف)',
'إدارة مفاتيح API (خرائط Google والطقس)',
'إدارة المستخدمين والصلاحيات',
'نسخ احتياطية تلقائية',
'إدارة الإضافات (تفعيل/تعطيل)',
'تسجيل دخول موحد OIDC / SSO',
],
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
addons: [
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
['Packing', 'قوائم تجهيز لكل رحلة'],
['Budget', 'تتبع المصروفات مع التقسيم'],
['Documents', 'إرفاق الملفات بالرحلات'],
['Widgets', 'محول عملات ومناطق زمنية'],
],
whatIs: 'ما هو TREK؟',
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
selfHost: 'مفتوح المصدر — ',
selfHostLink: 'استضفه بنفسك',
close: 'فهمت',
},
id: {
titleBefore: 'Selamat datang di ',
titleAfter: '',
title: 'Selamat datang di Demo TREK',
description:
'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
resetIn: 'Atur ulang berikutnya dalam',
minutes: 'menit',
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
fullVersionTitle: 'Selain itu dalam versi lengkap:',
features: [
'Unggah file (foto, dokumen, sampul)',
'Manajemen kunci API (Google Maps, Cuaca)',
'Manajemen pengguna & izin',
'Pencadangan otomatis',
'Manajemen Addon (aktifkan/nonaktifkan)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
addons: [
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
['Pengepakan', 'Daftar periksa per perjalanan'],
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
['Dokumen', 'Lampirkan file ke perjalanan'],
['Widget', 'Konverter mata uang & zona waktu'],
],
whatIs: 'Apa itu TREK?',
whatIsDesc:
'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
selfHost: 'Buka sumber — ',
selfHostLink: 'host mandiri',
close: 'Mengerti',
},
};
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield];
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft];
export default function DemoBanner(): React.ReactElement | null {
const [dismissed, setDismissed] = useState<boolean>(false);
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes());
const { language } = useTranslation();
const t = texts[language] || texts.en;
useEffect(() => {
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
return () => clearInterval(interval);
}, []);
if (dismissed) return null;
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99999,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingTop: 'max(16px, env(safe-area-inset-top))',
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
paddingInline: 16,
overflow: 'auto',
fontFamily: 'var(--font-system)',
}}
role="button"
tabIndex={0}
aria-label={t.close}
onClick={() => setDismissed(true)}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDismissed(true);
}
}}
>
<div
role="presentation"
style={{
background: 'white',
borderRadius: 20,
padding: '28px 24px 0',
maxWidth: 480,
width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: 'min(90vh, calc(100dvh - 96px))',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2
style={{
margin: 0,
fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))',
fontWeight: 700,
color: '#111827',
display: 'flex',
alignItems: 'center',
gap: 5,
}}
>
{t.titleBefore}
<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />
{t.titleAfter}
</h2>
</div>
<p
style={{
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
color: '#6b7280',
lineHeight: 1.6,
margin: '0 0 12px',
}}
>
{t.description}
</p>
{/* Timer + Upload note */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 6,
background: '#f0f9ff',
border: '1px solid #bae6fd',
borderRadius: 10,
padding: '8px 10px',
}}
>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes}
</span>
</div>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 6,
background: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 10,
padding: '8px 10px',
}}
>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>
{t.uploadNote}
</span>
</div>
</div>
{/* What is TREK */}
<div
style={{
background: '#f8fafc',
borderRadius: 12,
padding: '12px 14px',
marginBottom: 16,
border: '1px solid #e2e8f0',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} />
<span
style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 700,
color: '#111827',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{t.whatIs}
</span>
</div>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>
{t.whatIsDesc}
</p>
</div>
{/* Addons */}
<p
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 700,
color: '#374151',
margin: '0 0 8px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Puzzle size={12} />
{t.addonsTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.addons.map(([name, desc], i) => {
const Icon = addonIcons[i];
return (
<div
key={name}
style={{
background: '#f8fafc',
borderRadius: 10,
padding: '8px 10px',
border: '1px solid #f1f5f9',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}
>
{name}
</span>
</div>
<p
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
color: '#94a3b8',
margin: 0,
lineHeight: 1.3,
paddingInlineStart: 18,
}}
>
{desc}
</p>
</div>
);
})}
</div>
{/* Full version features */}
<p
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 700,
color: '#374151',
margin: '0 0 8px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Shield size={12} />
{t.fullVersionTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.features.map((text, i) => {
const Icon = featureIcons[i];
return (
<div
key={text}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: '#4b5563',
padding: '4px 0',
}}
>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span>
</div>
);
})}
</div>
{/* Footer */}
<div
style={{
padding: '14px 0 20px',
borderTop: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'sticky',
bottom: 0,
background: 'white',
marginTop: 'auto',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: '#9ca3af',
}}
>
<Github size={13} />
<span>{t.selfHost}</span>
<a
href="https://github.com/liketrek/TREK"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
>
{t.selfHostLink}
</a>
</div>
<button type="button"
onClick={() => setDismissed(true)}
style={{
background: '#111827',
color: 'white',
border: 'none',
borderRadius: 10,
padding: '8px 20px',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{t.close}
</button>
</div>
</div>
</div>
);
}
+5 -5
View File
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
import ErrorBoundary from '../shared/ErrorBoundary'
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
import { useGlRenderer } from './glSupport'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
@@ -28,11 +29,10 @@ export function MapViewAuto(props: MapViewAutoProps) {
const mapProps = { ...props, hazards: hazards.feed?.hazards } as MapViewProps
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token.
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
: null
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
// browser has no WebGL2 (both GL engines need it), so the trip planner never
// shows an empty map or one that fails to start.
const glProvider = useGlRenderer(provider, token)
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
// maplibre-gl out of each other's downloads.
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
@@ -0,0 +1,88 @@
/**
* The trip map without WebGL2 (FE-COMP-MAPAUTO-WEBGL-*).
*
* A browser that cannot give a GL engine its WebGL2 context sees the Leaflet map
* with the user's tiles, never a map that fails to start: up front when the probe
* says no, and the moment the GL map's own constructor is refused when it said yes.
*/
import { useEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { buildGlMap } from './glSupport';
import { MapViewAuto } from './MapViewAuto';
const gl = vi.hoisted(() => ({ refuse: false }));
vi.mock('./MapView', () => ({
MapView: () => <div data-testid="leaflet-map" />,
}));
/** Stands in for MapViewGL: builds its map the way the real one does, through buildGlMap. */
function FakeGlMap() {
useEffect(() => {
buildGlMap(() => {
if (!gl.refuse) return {};
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
throw err;
});
}, []);
return <div data-testid="gl-map" />;
}
vi.mock('./glLazy', () => ({ MapViewGLMaplibre: FakeGlMap, MapViewGLMapbox: FakeGlMap }));
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
const realGetContext = HTMLCanvasElement.prototype.getContext;
function browserWithWebGL2(available: boolean) {
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
available && type === 'webgl2' ? ctx : null
) as unknown as typeof realGetContext;
}
beforeEach(() => {
resetAllStores();
resetWebGLProbe();
gl.refuse = false;
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', mapbox_access_token: '' } });
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
describe('MapViewAuto without WebGL2', () => {
it('FE-COMP-MAPAUTO-WEBGL-001: draws the MapLibre map where WebGL2 is there', async () => {
browserWithWebGL2(true);
render(<MapViewAuto places={[]} />);
expect(await screen.findByTestId('gl-map')).toBeInTheDocument();
expect(screen.queryByTestId('leaflet-map')).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-WEBGL-002: draws the Leaflet map when the browser has no WebGL2', () => {
browserWithWebGL2(false);
render(<MapViewAuto places={[]} />);
expect(screen.getByTestId('leaflet-map')).toBeInTheDocument();
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-WEBGL-003: falls back to Leaflet when the GPU refuses the context the probe promised', async () => {
browserWithWebGL2(true);
gl.refuse = true;
render(<MapViewAuto places={[]} />);
expect(await screen.findByTestId('leaflet-map')).toBeInTheDocument();
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import { act } from '@testing-library/react';
import maplibregl from 'maplibre-gl';
import * as maplibregl from 'maplibre-gl';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
@@ -164,22 +164,21 @@ vi.mock('mapbox-gl', () => ({
}));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
// maplibre-gl 6 has no default export, so the fakes are named exports.
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
},
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
+17 -18
View File
@@ -8,7 +8,7 @@ import { buildPlace } from '../../../tests/helpers/factories'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import maplibregl from 'maplibre-gl'
import * as maplibregl from 'maplibre-gl'
import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
@@ -156,22 +156,21 @@ vi.mock('mapbox-gl', () => ({
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
// maplibre-gl 6 has no default export, so the fakes are named exports.
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts)
}),
LngLatBounds: vi.fn(function () {
return glBounds.create()
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup
}),
},
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts)
}),
LngLatBounds: vi.fn(function () {
return glBounds.create()
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup
}),
}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
@@ -352,7 +351,7 @@ describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
const maplibregl = await import('maplibre-gl')
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
@@ -373,7 +372,7 @@ describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-014: MapLibre maps disable the around-center mouse rotate (#1545)', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
const maplibregl = await import('maplibre-gl')
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
+5 -5
View File
@@ -24,6 +24,7 @@ import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import { buildGlMap } from './glSupport'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -1104,13 +1105,12 @@ export function MapViewGL({
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// MapLibre 5's mouse-rotate inverts its sign at a mid-screen line it gets by
// re-projecting the map center — a line that drifts with the bearing, so a
// right-button drag near mid-screen ping-pongs instead of rotating (#1545).
// aroundCenter: false restores the plain dx-based rotate mapbox-gl uses.
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: MapViewAuto has switched to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
popupRef.current = new gl.Popup({
closeButton: false,
+4 -1
View File
@@ -7,7 +7,8 @@ import {
RASTER_FALLBACK_TILE_URL,
attributionForTile,
} from '../../constants/mapDefaults'
import { hasWebGL } from '../../utils/webgl'
import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
import { isGpuInitFailure } from './glSupport'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
/** The Leaflet layer maplibre-gl-leaflet hands back. */
@@ -96,6 +97,8 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
layer.addTo(map)
} catch (err) {
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
// The GPU, not a context budget: the GL maps elsewhere in the app switch to Leaflet too.
if (isGpuInitFailure(err)) reportWebGLFailure()
detachBasemapLayer(layer)
return false
}
+18 -6
View File
@@ -1,7 +1,6 @@
import maplibregl from 'maplibre-gl'
import * as maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
import { registerRtlTextPlugin } from '../rtlText'
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
/**
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
@@ -11,9 +10,22 @@ import { registerRtlTextPlugin } from '../rtlText'
* Popup, LngLatBounds — which is why the components take the engine as a prop and
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
* on the provider (projection, accessToken, aroundCenter).
*
* MapLibre 6 ships as ES modules only and has no default export any more, so the
* namespace is what goes out as this module's default. It is a frozen module
* namespace: nothing may assign to it, which is why the accessToken writes stay
* behind their mapbox-only guard.
*/
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
// MapLibre 6 finds its worker next to its own module, which inside a bundle is a
// chunk in /assets/ with no worker beside it. Vite builds the worker into its own
// file and hands back the URL; set before any Map is constructed, the
// maplibre-gl-leaflet bridge included, since it only ever reaches the engine
// together with this module.
maplibregl.setWorkerUrl(workerUrl)
// No RTL text plugin here, unlike mapbox. MapLibre 6.9 shapes Arabic and orders
// bidirectional text itself; the old plugin would replace that implementation, and
// a v6 worker can only load the plugin's UMD build through eval.
export default maplibregl
@@ -1,29 +1,33 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The two lines this fix actually adds. Everything else is a helper: the guard in
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting either registration, swapping an
* argument or dropping the deferred flag left the whole suite green.
* What each engine module does on import. Everything else is a helper: the guard
* in rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting the mapbox registration, swapping an
* argument or dropping the deferred flag left the whole suite green, and so would
* a maplibre engine that forgot its worker URL.
*
* The engines are mocked because importing them for real builds a worker pool
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
*/
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
const { mapboxSet, maplibreSet, maplibreWorker } = vi.hoisted(() => ({
mapboxSet: vi.fn(),
maplibreSet: vi.fn(),
maplibreWorker: vi.fn(),
}));
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
vi.mock('maplibre-gl', () => ({ setRTLTextPlugin: maplibreSet, setWorkerUrl: maplibreWorker }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url', () => ({ default: '/assets/maplibre-worker.js' }));
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
beforeEach(() => {
vi.resetModules();
mapboxSet.mockReset();
maplibreSet.mockReset();
maplibreWorker.mockReset();
});
describe('the GL engines register the RTL text plugin (#2235)', () => {
@@ -37,13 +41,12 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
});
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
it('FE-MAP-RTL-002: importing the maplibre engine registers no plugin', async () => {
await import('./maplibre');
expect(maplibreSet).toHaveBeenCalledTimes(1);
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
// the call stays with each engine instead of going through one wrapper.
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
// MapLibre 6.9 shapes right-to-left text itself. The plugin would replace
// that, and a v6 worker could only run its UMD build through eval.
expect(maplibreSet).not.toHaveBeenCalled();
});
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
@@ -54,13 +57,13 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
expect(engine.default).toBeDefined();
});
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
it('FE-MAP-RTL-004: importing the maplibre engine points it at the bundled worker', async () => {
const engine = await import('./maplibre');
expect(engine.default).toBeDefined();
// Give the rejection a tick to go unhandled if nothing caught it.
await new Promise((resolve) => setTimeout(resolve, 0));
// MapLibre 6 looks for its worker next to its own module, which in a bundle
// is a chunk with no worker beside it. Without this no map draws a tile.
expect(maplibreWorker).toHaveBeenCalledTimes(1);
expect(maplibreWorker).toHaveBeenCalledWith('/assets/maplibre-worker.js');
expect(engine.default.setWorkerUrl).toBe(maplibreWorker);
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Whether a map may be drawn with a GL engine (FE-MAP-GLSUP-*).
*
* MapLibre 6 and Mapbox GL 3 throw out of their Map constructor without WebGL2.
* What is under test is the one decision every GL map goes through: the user's
* provider when it can run here, Leaflet otherwise, and Leaflet from the moment a
* GPU that claimed WebGL2 refuses the context after all.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasWebGL, resetWebGLProbe } from '../../utils/webgl';
import { buildGlMap, isGpuInitFailure, pickGlRenderer, useGlRenderer, useWebGL2 } from './glSupport';
const realGetContext = HTMLCanvasElement.prototype.getContext;
/** A browser whose canvas hands out a webgl2 context, or none. */
function browserWithWebGL2(available: boolean) {
const ctx = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) };
const getContext = vi.fn((type: string) => (available && type === 'webgl2' ? ctx : null));
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext;
return getContext;
}
function gpuError(): Error {
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
return err;
}
beforeEach(() => {
resetWebGLProbe();
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
describe('pickGlRenderer', () => {
it('FE-MAP-GLSUP-001: keeps the GL provider the user picked when WebGL2 is there', () => {
expect(pickGlRenderer('maplibre-gl', '', () => true)).toBe('maplibre-gl');
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => true)).toBe('mapbox-gl');
});
it('FE-MAP-GLSUP-002: draws with Leaflet when the browser has no WebGL2', () => {
expect(pickGlRenderer('maplibre-gl', '', () => false)).toBeNull();
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => false)).toBeNull();
});
it('FE-MAP-GLSUP-003: Mapbox without a token stays on Leaflet, MapLibre needs none', () => {
expect(pickGlRenderer('mapbox-gl', '', () => true)).toBeNull();
expect(pickGlRenderer('maplibre-gl', null, () => true)).toBe('maplibre-gl');
});
it('FE-MAP-GLSUP-004: a Leaflet user never pays for the probe', () => {
const webgl2 = vi.fn(() => true);
expect(pickGlRenderer('leaflet', 'pk.token', webgl2)).toBeNull();
expect(pickGlRenderer(undefined, undefined, webgl2)).toBeNull();
expect(webgl2).not.toHaveBeenCalled();
});
});
describe('isGpuInitFailure', () => {
it('FE-MAP-GLSUP-005: knows both engines’ way of saying the GPU refused', () => {
expect(isGpuInitFailure(gpuError())).toBe(true);
expect(isGpuInitFailure(new Error('Failed to initialize WebGL.'))).toBe(true);
});
it('FE-MAP-GLSUP-006: anything else is not a GPU failure', () => {
expect(isGpuInitFailure(new TypeError('container is null'))).toBe(false);
expect(isGpuInitFailure('Failed to initialize WebGL')).toBe(false);
expect(isGpuInitFailure(null)).toBe(false);
});
});
describe('buildGlMap', () => {
it('FE-MAP-GLSUP-007: hands back the map the engine built', () => {
const map = { remove: vi.fn() };
expect(buildGlMap(() => map)).toBe(map);
});
it('FE-MAP-GLSUP-008: a GPU that claimed WebGL2 and refused it gives null and turns the answer to no', () => {
browserWithWebGL2(true);
expect(hasWebGL()).toBe(true);
expect(
buildGlMap(() => {
throw gpuError();
})
).toBeNull();
expect(hasWebGL()).toBe(false);
});
it('FE-MAP-GLSUP-009: any other error is a bug and is thrown on', () => {
browserWithWebGL2(true);
expect(() =>
buildGlMap(() => {
throw new TypeError('style is undefined');
})
).toThrow('style is undefined');
expect(hasWebGL()).toBe(true);
});
});
describe('useGlRenderer', () => {
it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => {
const getContext = browserWithWebGL2(false);
const { result } = renderHook(() => useGlRenderer('maplibre-gl', ''));
expect(result.current).toBeNull();
expect(getContext.mock.calls.map((call) => call[0])).toEqual(['webgl2']);
});
it('FE-MAP-GLSUP-011: switches every mounted map to Leaflet once a GL map fails to build', () => {
browserWithWebGL2(true);
const trip = renderHook(() => useGlRenderer('maplibre-gl', ''));
const terrain = renderHook(() => useWebGL2());
expect(trip.result.current).toBe('maplibre-gl');
expect(terrain.result.current).toBe(true);
act(() => {
buildGlMap(() => {
throw gpuError();
});
});
expect(trip.result.current).toBeNull();
expect(terrain.result.current).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { useSyncExternalStore } from 'react';
import { hasWebGL, reportWebGLFailure, subscribeWebGL, webGLFailures } from '../../utils/webgl';
import type { GlMapProvider } from './glProviders';
/*
* Whether a map may be drawn with a GL engine at all, for every place that would
* build one: the trip map, the journey maps, the settings preview and the 3D tour.
*
* MapLibre 6 and Mapbox GL 3 both need WebGL2 and throw out of their Map
* constructor without it. A browser that cannot give it gets the Leaflet map with
* the user's tiles instead, never a map that fails to start. Like the GL
* renderers it guards, this module stays free of both SDKs (`check:gl-split`).
*/
/**
* The GL engine to draw with, or null for Leaflet: the user's provider, as long
* as it can run here. Mapbox also needs a token, MapLibre does not.
*/
export function pickGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined,
webgl2: () => boolean = hasWebGL
): GlMapProvider | null {
const wanted: GlMapProvider | null =
provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && mapboxToken ? 'mapbox-gl' : null;
// Asked only for a GL provider: a Leaflet user never pays for the probe's context.
return wanted && webgl2() ? wanted : null;
}
/** Re-renders the caller when a GL map found out that WebGL2 does not work after all. */
function useWebGLFailures(): number {
return useSyncExternalStore(subscribeWebGL, webGLFailures, webGLFailures);
}
/** pickGlRenderer for a component, switching to Leaflet the moment a GL map fails to build. */
export function useGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined
): GlMapProvider | null {
useWebGLFailures();
return pickGlRenderer(provider, mapboxToken);
}
/** Whether 3D (the GL terrain map) can run here, kept current the same way. */
export function useWebGL2(): boolean {
useWebGLFailures();
return hasWebGL();
}
/**
* The GPU refusing a context, as either engine reports it from its Map
* constructor. Matched on the name and message because the class itself lives in
* the maplibre chunk, and Mapbox throws a plain Error.
*/
export function isGpuInitFailure(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return err.name === 'GPUInitializationError' || /failed to initialize webgl/i.test(err.message);
}
/**
* Builds a GL map, the second line of defence behind the probe: a GPU that
* claims WebGL2 and still fails is reported, so every map switches to Leaflet,
* and the caller gets null instead of a throw. Anything else is a bug and is
* thrown on.
*/
export function buildGlMap<T>(construct: () => T): T | null {
try {
return construct();
} catch (err) {
if (!isGpuInitFailure(err)) throw err;
reportWebGLFailure();
return null;
}
}
+10 -8
View File
@@ -7,16 +7,18 @@
* this because their labels are drawn server-side, which is why it only surfaced
* once the default basemap became a vector style.
*
* Both engines expose the fix as an optional plugin and neither loads it by
* default. Registering is global to the engine, so it happens once per engine
* module rather than at each map's construction site.
* Mapbox GL exposes the fix as an optional plugin and does not load it by
* default. Registering is global to the engine, so it happens once in the engine
* module rather than at each map's construction site. MapLibre needed the same
* plugin up to version 5; since 6.9 it shapes and reorders the text itself, and
* its engine module registers nothing.
*
* The call itself stays with the caller: this is one of the places the two engines
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
* module passes its own invocation rather than a shared wrapper guessing an arity.
* The call itself stays with the caller: Mapbox GL 3 takes
* `(url, callback, deferred)` and returns nothing, while MapLibre took
* `(url, lazy)` and returned a promise, so the guard below accepts either rather
* than guessing an arity.
*
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
* Deliberately no getRTLTextPluginStatus() pre-check: reading it made MapLibre
* build its worker pool, which is an eager side effect for a module that is
* imported for its default export, and throws outright under jsdom. Registering
* twice is reported — by a throw or a rejected promise depending on the engine —
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn(), reportWebGLFailure: vi.fn() }))
const { hasWebGL } = await import('../../utils/webgl')
const { renderTripMapImage } = await import('./tripMapImage')
+11 -6
View File
@@ -1,6 +1,7 @@
import { buildRouteOverlay, wrapSvg } from './tripMapSvg'
import { hasWebGL } from '../../utils/webgl'
import { normalizeStyleForProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
import { importChunk } from '../../utils/chunkReload'
@@ -49,18 +50,20 @@ export async function renderTripMapImage(
try {
const gl = (await importChunk(() => import('../Map/engines/maplibre'))).default
container = document.createElement('div')
// A const of its own: the map is built inside a closure, where the let's narrowing is gone.
const box = document.createElement('div')
container = box
// Off-screen rather than hidden: MapLibre needs a laid-out box with real
// dimensions, and `display:none` gives it a zero-sized canvas.
container.style.cssText =
box.style.cssText =
`position:fixed;left:-10000px;top:0;width:${width}px;height:${height}px;pointer-events:none;`
document.body.appendChild(container)
document.body.appendChild(box)
const bounds = new gl.LngLatBounds()
for (const [lat, lng] of points) bounds.extend([lng, lat])
const instance = new gl.Map({
container,
const instance = buildGlMap(() => new gl.Map({
container: box,
style: normalizeStyleForProvider('maplibre-gl', opts.style),
bounds,
fitBoundsOptions: { padding: 42, animate: false },
@@ -70,7 +73,9 @@ export async function renderTripMapImage(
// MapLibre 5 moved the WebGL flags off the top level into this bag.
canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true },
fadeDuration: 0,
})
}))
// A GPU that offered WebGL2 and then refused it: reported, so the app's maps follow.
if (!instance) return null
map = instance
const ready = await new Promise<boolean>(resolve => {
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import AccountDemoLock from './AccountDemoLock';
beforeEach(() => resetAllStores());
function renderCards() {
return render(
<AccountDemoLock>
<button type="button">Save profile</button>
</AccountDemoLock>
);
}
describe('AccountDemoLock', () => {
it('FE-COMP-ACCOUNTDEMOLOCK-001: the shared demo account sees its cards under the lock, with the reason', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
const { container } = renderCards();
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
expect(screen.queryByRole('button', { name: 'Save profile' })).not.toBeInTheDocument();
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
screen.getByRole('button', { hidden: true, name: 'Save profile' })
);
});
it('FE-COMP-ACCOUNTDEMOLOCK-002: the admin of a demo instance gets the cards as they are', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
const { container } = renderCards();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
expect(container.querySelector('[data-demo-lock]')).toBeNull();
});
it('FE-COMP-ACCOUNTDEMOLOCK-003: the demo address on an instance that is not a demo is left alone', () => {
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
renderCards();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
});
});
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import DemoLock from '../Demo/DemoLock';
import { useDemoAccountLock } from '../Demo/useDemoLock';
/**
* The account cards under the demo lock, on the desktop tab and the phone
* screen alike. Every visitor of a demo instance signs in as the one shared
* demo account, so its profile, password, two factor setup and passkeys stay
* on view but out of reach, while the admin of a demo instance and every
* account of a real one get the cards as they are. Both views wrap their
* cards in this, so whom the lock holds and the reason it gives are written
* once.
*/
export default function AccountDemoLock({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const locked = useDemoAccountLock();
return (
<DemoLock locked={locked} reason={t('demo.lock.account')}>
{children}
</DemoLock>
);
}
@@ -395,10 +395,20 @@ describe('AccountTab – MFA', () => {
expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument();
});
it('FE-COMP-ACCOUNT-036: MFA section shows demoBlocked message in demo mode', () => {
seedStore(useAuthStore, { demoMode: true });
it('FE-COMP-ACCOUNT-036: the shared demo account sees the whole tab under the demo lock', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
const { container } = render(<AccountTab />);
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
screen.getByRole('button', { hidden: true, name: /Set up authenticator/ }),
);
});
it('FE-COMP-ACCOUNT-036b: the admin of a demo instance keeps the tab unlocked, 2FA included', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin', mfa_enabled: false }) });
render(<AccountTab />);
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Set up authenticator/ })).toBeInTheDocument();
});
});
@@ -12,6 +12,7 @@ import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
} from './settingsKit'
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
import AccountDemoLock from './AccountDemoLock'
/** The small turning ring a button shows while its request is on the way. */
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
@@ -71,6 +72,8 @@ export default function AccountTab(): React.ReactElement {
return (
<>
{/* The shared demo account: everything here is shown, nothing can be changed. */}
<AccountDemoLock>
<Section title={t('settings.account')} icon={User}>
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
@@ -221,10 +224,7 @@ export default function AccountTab(): React.ReactElement {
</div>
)}
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
{demoMode ? (
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<>
<SettingRows>
<SettingRow
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
@@ -357,12 +357,12 @@ export default function AccountTab(): React.ReactElement {
</div>
</div>
)}
</>
)}
</>
</Section>
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
</AccountDemoLock>
{/* Delete Account Blocked */}
<DialogShell
@@ -483,3 +483,18 @@ describe('DisplaySettingsTab week start (#2029)', () => {
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});
describe('DisplaySettingsTab – the demo account', () => {
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), isLoaded: true });
render(<DisplaySettingsTab />);
expect(screen.getByRole('note')).toHaveTextContent('The demo always runs in English.');
});
it('FE-COMP-DISPLAY-DEMO-002: any other account of a demo instance picks its language freely', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
render(<DisplaySettingsTab />);
expect(screen.queryByRole('note')).not.toBeInTheDocument();
});
});
@@ -18,6 +18,8 @@ import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDes
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
const SELECT_BOX = 'w-[260px] max-w-full'
@@ -25,6 +27,7 @@ const SELECT_BOX = 'w-[260px] max-w-full'
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, save } = useSettingSaver()
const { t, locale } = useTranslation()
const demoLocked = useDemoAccountLock()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
const [langOpen, setLangOpen] = useState(false)
@@ -123,7 +126,9 @@ export default function DisplaySettingsTab(): React.ReactElement {
}
/>
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
The shared demo account always runs in English. */}
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
<SettingRow label={t('settings.language')} stacked>
<div className="hidden sm:block">
<ChoiceChips
@@ -162,6 +167,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
)}
</div>
</SettingRow>
</DemoLock>
{/* Place names (#1799): searches can answer in another language than the app. */}
<SettingRow
@@ -18,10 +18,13 @@ import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, Setting
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
import { useIntegrationGates } from './useIntegrationGates'
import WandererConnectionSection from './WandererConnectionSection'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
const managed = S.managed
const demoLocked = useDemoAccountLock()
return (
<>
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
@@ -42,9 +45,15 @@ export default function IntegrationsTab(): React.ReactElement {
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
one should not have to read past a section about AI assistants. */}
<ApiKeysSection />
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
<ApiKeysSection />
</DemoLock>
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
{S.mcpEnabled && (
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
<IntegrationsMcpSection S={S} />
</DemoLock>
)}
<McpTokenModals S={S} />
<OAuthClientModals S={S} />
</>
@@ -33,6 +33,12 @@ vi.mock('./MapboxPreview', () => ({
),
}));
// jsdom has no WebGL2; these cases are about a browser that has it.
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => true,
}));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
@@ -132,7 +132,7 @@ export default function MapSettingsTab(): React.ReactElement {
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, save: saveMapSettings,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
// One chunk per engine — see components/Map/glLazy.tsx.
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
@@ -318,7 +318,7 @@ export default function MapSettingsTab(): React.ReactElement {
)}
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
{provider !== 'leaflet' ? (
{glPreview ? (
/* A net of its own: the preview is the one place a user flips providers
live, so it is the likeliest chunk to fail — and a broken preview must
not take the rest of the settings tab with it. */
@@ -363,6 +363,7 @@ export default function MapSettingsTab(): React.ReactElement {
})
)}
</div>
{webgl2Missing && <SettingsHint>{t('map.webgl2.fallback')}</SettingsHint>}
<div className="flex justify-end">
<button type="button"
@@ -32,7 +32,7 @@ const mapboxModule = vi.hoisted(() => ({ accessToken: '', Map: mapboxCtor }));
vi.mock('mapbox-gl', () => ({ default: mapboxModule }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl', () => ({ default: { Map: maplibreCtor } }));
vi.mock('maplibre-gl', () => ({ Map: maplibreCtor }));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('../Map/mapboxSetup', () => ({
@@ -45,7 +45,7 @@ vi.mock('../Map/mapboxSetup', () => ({
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup';
import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders';
import mapboxgl from 'mapbox-gl';
import maplibregl from 'maplibre-gl';
import * as maplibregl from 'maplibre-gl';
import GlMapPreviewWithEngine from './MapboxPreview';
// The engine is a prop now, not a module import — that is what keeps mapbox-gl and
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import { fs } from '../shared/DialogShell'
interface Props {
@@ -46,11 +47,13 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
antialias: quality,
}
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: the settings form has switched the preview to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
map.on('load', () => {
@@ -140,4 +140,13 @@ describe('AiConnectionSection', () => {
const { container } = render(<AiConnectionSection />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
});
it('FE-COMP-AI-CONNECTION-008: the shared demo account sees its own connection locked, and no admin hint', async () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
render(<AiConnectionSection />);
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
expect(screen.getByRole('button', { hidden: true, name: 'Connect your own provider' })).toBeDisabled();
});
});
@@ -3,6 +3,8 @@ import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
import DemoLock from '../../Demo/DemoLock';
import { useDemoAccountLock } from '../../Demo/useDemoLock';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { fs } from '../../shared/DialogShell';
@@ -202,6 +204,25 @@ function AssistantBehaviour() {
);
}
/** Where the shared demo account would connect a provider of its own: shown, and locked. */
function LockedOwnConnection() {
const { t } = useTranslation();
return (
<DemoLock locked reason={t('demo.lock.ai')} size="row">
<SettingRows>
<SettingRow
label={t('demo.lock.aiTitle')}
control={
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
{t('assistant.errorAction.connectOwn')}
</button>
}
/>
</SettingRows>
</DemoLock>
);
}
/** The connect panel, opened on purpose or because nothing is connected yet. */
function ConnectPanel({ S }: { S: AiConnections }) {
const { t } = useTranslation();
@@ -254,6 +275,7 @@ export default function AiConnectionSection({
}): React.ReactElement | null {
const { t } = useTranslation();
const S = useAiConnections();
const demoLocked = useDemoAccountLock();
const text = aiSectionText(assistant, parsing);
if (S.view === 'hidden') return null;
const own = S.own;
@@ -277,7 +299,8 @@ export default function AiConnectionSection({
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
{S.view === 'instance' && <InstanceConnection S={S} />}
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{S.view === 'adminOnly' && !demoLocked && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{demoLocked && S.view !== 'loading' && <LockedOwnConnection />}
{S.panel && <ConnectPanel S={S} />}
<SectionFooter S={S} />
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Place } from '../../types';
import { normalizeStyleForProvider } from '../Map/glProviders';
import { useWebGL2 } from '../Map/glSupport';
import { useToast } from '../shared/Toast';
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
@@ -51,6 +52,11 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
}, [settings]);
const previewPlaces = useMemo((): Place[] => [previewPlace()], []);
// Both GL engines need WebGL2. Without it the preview shows the Leaflet map the
// trip planner falls back to, and both shells say why.
const webgl2 = useWebGL2();
const glPreview = provider !== 'leaflet' && webgl2;
const webgl2Missing = provider !== 'leaflet' && !webgl2;
const save = async (): Promise<void> => {
setSaving(true);
@@ -105,6 +111,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
mapboxQuality,
setMapboxQuality,
previewPlaces,
glPreview,
webgl2Missing,
save,
};
}
@@ -1,6 +1,7 @@
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
import { buildGlMap } from '../../Map/glSupport';
import { isTourLayerHit } from '../../Map/mapRasterOverlay';
import {
addTerrainLayers,
@@ -110,8 +111,11 @@ export function useTerrainMap({ scene, baseLayer, data, exaggeration }: Options)
let map: MapLibreMap | null = null;
void import('../../Map/engines/maplibre').then(({ default: engine }) => {
if (cancelled) return;
map = createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle });
setGl({ map, engine });
// Null when the GPU refused WebGL2. The report switches the planner back to the 2D map.
map = buildGlMap(() =>
createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle })
);
if (map) setGl({ map, engine });
});
return () => {
cancelled = true;
@@ -3,7 +3,7 @@ import { useMatch, useNavigate } from 'react-router';
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
import { useAssistantHome } from '../../../components/Assistant/useAssistantHome';
import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome';
import { useAssistantStore } from '../../../store/assistantStore';
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
@@ -15,8 +15,9 @@ import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScree
* screen and the question waiting in its composer. Another page a card points
* at closes the chat and opens there.
*
* Inside a trip the trip's own screen (MAssistantScreen) shows its chat, so
* this one stays away there. Nothing shows until the assistant has a
* On a journal's page it shows that journal's chat instead. Inside a trip
* the trip's own screen (MAssistantScreen) shows its chat, so this one stays
* away there. Nothing shows until the assistant has a
* connection this user can use.
*/
export default function MAssistantHomeScreen() {
@@ -28,9 +29,12 @@ export default function MAssistantHomeScreen() {
const loadStatus = useAssistantStore((s) => s.loadStatus);
const close = useAssistantStore((s) => s.close);
// The trip's screen takes over with the window still open, so its chat comes up there.
const switchTo = useCallback((trip: AssistantChatTrip) => void navigate(`/trips/${trip.id}`), [navigate]);
// The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there.
const switchTo = useCallback((chat: AssistantChatTrip) => void navigate(chatPagePath(chat)), [navigate]);
const home = useAssistantHome(switchTo);
// On a journal's page the chat is that journal's, the journey's conversation it shares with TREK Studio.
const journal = useRouteJournalChat();
const chat = journal ?? home.chat;
const hops = useMemo(
(): MapHops => ({
open: (target: EntityTarget) => {
@@ -50,11 +54,11 @@ export default function MAssistantHomeScreen() {
if (inTrip || !available || !open || minimized) return null;
return (
<AssistantScreen
tripId={home.chat.id}
tripTitle={home.chat.title}
tripId={chat.id}
tripTitle={chat.title}
hidden={false}
hops={hops}
onOpenTripChat={home.openTripChat}
onOpenTripChat={journal ? undefined : home.openTripChat}
/>
);
}
@@ -14,7 +14,7 @@ import { useInAppNotificationStore } from '../../../store/inAppNotificationStore
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
import type { TripCardBadge } from '../../../api/client'
import DemoBanner from '../../../components/Layout/DemoBanner'
import DemoWelcome from '../../../components/Demo/DemoWelcome'
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
import PluginWidgets from '../../../components/Plugins/PluginWidgets'
import { entityGradient } from '../../../utils/gradients'
@@ -247,7 +247,7 @@ export default function MDashboard(): React.ReactElement {
<MUserMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
{demoMode && <DemoBanner />}
{demoMode && <DemoWelcome />}
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
{loadError && (
@@ -2,8 +2,11 @@
import userEvent from '@testing-library/user-event';
import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../tests/helpers/factories';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useAssistantStore } from '../../../store/assistantStore';
import { useAuthStore } from '../../../store/authStore';
const repo = vi.hoisted(() => ({
status: vi.fn(),
@@ -83,4 +86,20 @@ describe('MAiConnectionSection', () => {
expect(await screen.findByText('What would you like to chat with?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Paste an API key/ })).toBeInTheDocument();
});
it('FE-MOB-AI-DEMO-001: the shared demo account sees the place for its own connection locked', async () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
render(<MAiConnectionSection />);
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
});
it('FE-MOB-AI-DEMO-002: any other account keeps the admin hint', async () => {
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
render(<MAiConnectionSection />);
expect(await screen.findByText('Your admin sets this up.')).toBeInTheDocument();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
});
});
@@ -2,6 +2,8 @@ import type { AiConnectionView } from '@trek/shared';
import { ChevronDown, Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
import React, { useState } from 'react';
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../../components/Assistant/useAssistantMapFollow';
import DemoLock from '../../../components/Demo/DemoLock';
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock';
import AiConnectPanel from '../../../components/Settings/ai/AiConnectPanel';
import { aiSectionText, CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
import { useAiConnections } from '../../../components/Settings/ai/useAiConnections';
@@ -117,6 +119,30 @@ function MInstanceConnection({ S }: { S: AiConnections }) {
);
}
/**
* Under the connection: the hint that only the admin connects one, or, for the
* shared demo account, the locked place where its own would go.
*/
function MOwnConnectionNote({ view }: { view: AiConnections['view'] }) {
const { t } = useTranslation();
const demoLocked = useDemoAccountLock();
if (view === 'loading') return null;
if (!demoLocked) return view === 'adminOnly' ? <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint> : null;
return (
<DemoLock locked reason={t('demo.lock.ai')} size="row" className="mt-3">
<MSetRow
first
label={t('demo.lock.aiTitle')}
trailing={
<MSetButton variant="ghost" disabled>
{t('assistant.errorAction.connectOwn')}
</MSetButton>
}
/>
</DemoLock>
);
}
/** Whether the trip map follows the assistant, the phone's row for the shared switch. */
function MAssistantBehaviour() {
const { t } = useTranslation();
@@ -180,7 +206,7 @@ export default function MAiConnectionSection({
{S.view === 'own' && S.own && <MOwnConnection S={S} own={S.own} />}
{S.view === 'instance' && <MInstanceConnection S={S} />}
{assistant && (S.view === 'own' || S.view === 'instance') && <MAssistantBehaviour />}
{S.view === 'adminOnly' && <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint>}
<MOwnConnectionNote view={S.view} />
{S.panel && (
<div
@@ -22,6 +22,7 @@ import MConfirmSheet from './MConfirmSheet'
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
import AccountDemoLock from '../../../components/Settings/AccountDemoLock'
/**
* "Account" section — AccountTab parity: profile + avatar, password change,
@@ -78,6 +79,8 @@ export default function MSettingsAccount() {
return (
<>
{/* The shared demo account: everything here is shown, nothing can be changed. */}
<AccountDemoLock>
{/* ── Profile ─────────────────────────────────────────────── */}
<MSetCard title={t('settings.account')} icon={User}>
<div className="mb-4 flex items-center gap-4">
@@ -185,10 +188,7 @@ export default function MSettingsAccount() {
)}
<p className="text-[0.75rem] leading-relaxed text-m-muted">{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="mt-2 text-[0.75rem] font-semibold text-[color:var(--m-st-pending)]">{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<>
<p className="mt-2 text-[0.78125rem] font-bold text-m-ink">
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p>
@@ -287,11 +287,11 @@ export default function MSettingsAccount() {
</div>
</div>
)}
</>
)}
</>
</MSetCard>
<MPasskeysCard demoMode={demoMode} />
</AccountDemoLock>
<MConfirmSheet
open={showDeleteConfirm === 'blocked'}
@@ -12,6 +12,8 @@ import { MSetCard, MSetEyebrow, MSetHint, MSetSelectRow, MSetSegments, MSetRow }
import MToggle from '../../components/MToggle'
import MSetPickerSheet from './MSetPickerSheet'
import { preferredNavAppOptions } from '../../../components/Planner/placeNavigation'
import DemoLock from '../../../components/Demo/DemoLock'
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
/**
* "General" settings section — the demo's Language & region and Travel & map
@@ -20,6 +22,7 @@ import { preferredNavAppOptions } from '../../../components/Planner/placeNavigat
export default function MSettingsGeneral() {
const { t, locale } = useTranslation()
const { settings, save } = useSettingSaver()
const demoLocked = useDemoAccountLock()
const [currencyOpen, setCurrencyOpen] = useState(false)
const [langOpen, setLangOpen] = useState(false)
const [startTabOpen, setStartTabOpen] = useState(false)
@@ -121,8 +124,11 @@ export default function MSettingsGeneral() {
<MSetEyebrow className="mb-[5px]">{t('settings.currency')}</MSetEyebrow>
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
{/* The shared demo account always runs in English. */}
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
</DemoLock>
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.temperature')}</MSetEyebrow>
<MSetSegments
@@ -22,7 +22,7 @@ export default function MSettingsMap() {
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, save,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
@@ -170,7 +170,7 @@ export default function MSettingsMap() {
)}
<div className="relative mt-3 h-[200px] w-full overflow-hidden rounded-xl">
{provider !== 'leaflet' ? (
{glPreview ? (
/* See MapSettingsTab: the preview gets its own net. */
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-m-card" />}>
<Suspense fallback={<div className="h-full w-full bg-m-card animate-pulse" />}>
@@ -208,6 +208,7 @@ export default function MSettingsMap() {
})
)}
</div>
{webgl2Missing && <MSetHint>{t('map.webgl2.fallback')}</MSetHint>}
<MSetButton className="mt-3" onClick={save} disabled={saving}>
<Save size={14} />
@@ -8,6 +8,8 @@ import MChip from '../../components/MChip'
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet'
import MSegmented from '../../components/MSegmented'
import DemoLock from '../../../components/Demo/DemoLock'
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
function CopyButton({ text, copied, onCopy }: { text: string; copied: boolean; onCopy: (text: string) => void }) {
return (
@@ -37,6 +39,7 @@ function CodeBlock({ children }: { children: string }) {
*/
export default function MSettingsMcp() {
const { t, locale } = useTranslation()
const demoLocked = useDemoAccountLock()
const {
activeMcpTab: activeTab,
setActiveMcpTab: setActiveTab,
@@ -119,6 +122,7 @@ export default function MSettingsMcp() {
)
return (
<DemoLock locked={demoLocked} reason={t('demo.lock.mcp')}>
<MSetCard title={t('settings.mcp.title')} icon={Terminal} className="mt-3">
<MSetEyebrow className="mb-[5px]">{t('settings.mcp.endpoint')}</MSetEyebrow>
<div className="flex items-center gap-2">
@@ -518,5 +522,6 @@ export default function MSettingsMcp() {
onConfirm={() => tokenDeleteId !== null && deleteToken(tokenDeleteId)}
/>
</MSetCard>
</DemoLock>
)
}
@@ -317,6 +317,8 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
onFocusDay={hops.focusDay}
onRevealMap={hops.revealMap}
onOpenTripChat={onOpenTripChat}
// The phone's Studio has its own panel: a journey's chat here is the journal's.
journal={assistantScopeOf(tripId).kind === 'journey'}
/>
</div>
@@ -331,7 +331,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
{hasReply && (
<div className="mb-1 rounded-[10px] bg-[color:var(--m-card)] px-[10px] py-[5px]">
<div className="font-geist text-[0.625rem] font-bold opacity-70">{msg.reply_username}</div>
<div className="truncate text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
<div className="line-clamp-2 text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
</div>
)}
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import DemoBanner from '../components/Layout/DemoBanner'
import DemoWelcome from '../components/Demo/DemoWelcome'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
@@ -138,7 +138,7 @@ function DashboardPageDesktop(): React.ReactElement {
<Navbar />
<HelpAnchor id="dashboard" />
<div className="trek-dash trek-dash-shell">
{demoMode && <DemoBanner />}
{demoMode && <DemoWelcome />}
<div className="trek-dash-scroll">
<MobileTopBar />
<main className="page" data-no-sidebar={sidebarVisible ? undefined : 'true'}>
@@ -5,11 +5,13 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/
import { idleLegVariants, idleRoundHikes } from '../../../tests/helpers/tourPlannerStub';
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
import type { MapViewProps } from '../../components/Map/mapViewContract';
import { ToastContainer } from '../../components/shared/Toast';
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTourMapViewportStore } from '../../store/tourMapViewportStore';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
type Handler = (event?: unknown) => void;
@@ -75,6 +77,12 @@ const stage = vi.hoisted(() => {
});
vi.mock('../../components/Map/engines/maplibre', () => ({ default: stage.engine }));
// jsdom has no WebGL2: the stage is told it is there, except where a case takes it away.
const webgl = vi.hoisted(() => ({ available: true }));
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => webgl.available,
}));
vi.mock('../../components/Map/MapViewAuto', () => ({
MapViewAuto: (props: MapViewProps) => {
stage.flatMap.props = props;
@@ -185,6 +193,8 @@ async function openTerrain() {
}
beforeEach(() => {
webgl.available = true;
resetWebGLProbe();
window.sessionStorage.clear();
useTourPlaceToolStore.setState(initialTourPlaceToolState());
stage.listeners.clear();
@@ -928,3 +938,44 @@ describe('TourPlannerMapStage', () => {
});
});
});
describe('3D without WebGL2', () => {
// The stage translates with the t it is handed, and stageProps hands it one that returns the key.
const NO_3D = 'map.webgl2.terrain';
function renderWithToasts(tourPlanner: Planner) {
return render(
<>
<ToastContainer />
<TourPlannerMapStage {...stageProps(tourPlanner, true)} />
</>
);
}
it('FE-PAGE-TOURSTAGE-WEBGL-001: the 3D switch keeps the 2D map and says why', async () => {
webgl.available = false;
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).not.toHaveBeenCalled();
});
it('FE-PAGE-TOURSTAGE-WEBGL-002: a GPU that refuses the terrain context brings the 2D map back', async () => {
stage.engine.Map.mockImplementationOnce(function () {
webgl.available = false;
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
throw err;
});
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).toHaveBeenCalledTimes(1);
});
});
@@ -5,11 +5,13 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { useWebGL2 } from '../../components/Map/glSupport';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
import LocationSelect from '../../components/Planner/LocationSelect';
import { useToast } from '../../components/shared/Toast';
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
@@ -25,6 +27,7 @@ import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddle
import { useSettingsStore } from '../../store/settingsStore';
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
import { publishTourMapViewport } from '../../store/tourMapViewportStore';
import { subscribeWebGL } from '../../utils/webgl';
import { useFerrataAdopt } from './useFerrataAdopt';
import { useOneCornerCard } from './useOneCornerCard';
import { useTourMapPoints } from './useTourMapPoints';
@@ -112,7 +115,22 @@ export function TourPlannerMapStage({
paddleChecklist,
assistantRoutes,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
const [terrainAsked, setTerrain3d] = useState(false);
// The terrain is a MapLibre map, so it needs WebGL2. Without it, or once its GPU
// refused the context, the stage stays on the 2D map and says why.
const webgl2 = useWebGL2();
const terrain3d = terrainAsked && webgl2;
const toast = useToast();
const openTerrain = useCallback(() => {
if (webgl2) setTerrain3d(true);
else toast.info(t('map.webgl2.terrain'));
}, [webgl2, toast, t]);
// A GPU that offered WebGL2 and then refused the terrain's context: the 2D map is
// back by itself, and this says why.
useEffect(() => {
if (!terrainAsked) return;
return subscribeWebGL(() => toast.info(t('map.webgl2.terrain')));
}, [terrainAsked, toast, t]);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
const [terrainLegend, setTerrainLegend] = useState<TerrainLegendLayout | null>(null);
@@ -287,7 +305,7 @@ export function TourPlannerMapStage({
viewBaseLayer={baseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
tourLayers={tourLayers}
onToggleTerrain3d={() => setTerrain3d(true)}
onToggleTerrain3d={openTerrain}
ferrataLayer={ferrataLayer}
onViewportChange={viewportChanged}
placeTool={placeTool}
@@ -96,6 +96,20 @@ describe('assistantStore: a chat hosted by the Studio', () => {
expect(send.mock.calls[1]?.[1]).not.toHaveProperty('renders');
});
it("FE-AI-STORE-STUDIO-008: a journey's turn names its page, the Studio's while hosted, else the journal's", async () => {
const release = store().hostStudio(KEY, fakeHost([]));
const send = vi.spyOn(assistantRepo, 'send').mockImplementation(async () => streamOf([{ type: 'finish' }]));
await store().send(KEY, 'Lay out the cover');
release();
await store().send(KEY, 'A new entry for Berlin Hauptbahnhof');
await store().send(5, 'A trip question');
expect(send.mock.calls[0]?.[1]).toMatchObject({ surface: 'studio' });
expect(send.mock.calls[1]?.[1]).toMatchObject({ surface: 'journal' });
expect(send.mock.calls[2]?.[1]).not.toHaveProperty('surface');
});
it('FE-AI-STORE-STUDIO-003: hands the page a turn asks for to the host, with a signal that ends with the turn', async () => {
const host = fakeHost([]);
store().hostStudio(KEY, host);
+10 -4
View File
@@ -7,6 +7,7 @@ import {
type AssistantErrorCode,
type AssistantFocus,
type AssistantFoundPlace,
type AssistantJourneySurface,
type AssistantMapView,
type AssistantMention,
type AssistantMessage,
@@ -270,16 +271,21 @@ async function settleHolds(ctx: Ctx, key: AssistantChatKey): Promise<void> {
/** The Studio hosting the chat under `key`, if one does. */
const studioHostOf = (ctx: Ctx, key: AssistantChatKey): AssistantStudioHost | undefined => ctx.get().studioHosts[key];
/** A turn of a hosted chat writes the book first and says the Studio draws pages; any other turn opens as it is. */
async function hostedOpen<T>(ctx: Ctx, key: AssistantChatKey, open: (renders: { renders?: true }) => Promise<T>) {
type HostedFields = { renders?: true; surface?: AssistantJourneySurface };
/**
* A turn of a hosted chat writes the book first and says the Studio draws pages; any other turn opens as it is.
* A journey's turn names its page: the Studio's while the Studio hosts the chat, else the journal's.
*/
async function hostedOpen<T>(ctx: Ctx, key: AssistantChatKey, open: (fields: HostedFields) => Promise<T>) {
const host = studioHostOf(ctx, key);
if (!host) return open({});
if (!host) return open(assistantScopeOf(key).kind === 'journey' ? { surface: 'journal' } : {});
try {
await host.flush();
} catch {
// The Studio's save indicator shows its own failure; the turn goes on against the book the server holds.
}
return open({ renders: true });
return open({ renders: true, surface: 'studio' });
}
const NO_LIVE: AssistantTripChat['live'] = { activity: null };

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