Compare commits

...

116 Commits

Author SHA1 Message Date
Maurice 8ba7add541 fix(plugins): give the runtime test's plugins table the accepted-range columns, and move the theme entry with the menu code 2026-10-26 09:49:22 +01:00
Maurice a3664e7117 fix(plugins): lower the unused-key entry the moved components reach again, and test the marked install 2026-10-26 09:49:22 +01:00
Maurice 64ed2175a5 fix(plugins): keep the API client and the panel test within their size entries, drop an unused import 2026-10-26 09:49:22 +01:00
Maurice 447ae54b93 fix(i18n): translate the plugin filter button where it read as English 2026-10-26 09:49:22 +01:00
Maurice 0067b0ff19 fix(plugins): let an admin run a plugin outside its TREK range, and tidy the plugins panel
TREK 5.0 left most registry plugins blocked because their authors still declare
"<5.0.0", and the only way past that was TREK_PLUGINS_IGNORE_TREK_RANGE for every
plugin at once. An admin can now install or switch on one such plugin after a
warning; the consent is kept on its row for that plugin version and TREK major,
so an update or a new major asks again. The environment switch works as before.

Discover marks a plugin not confirmed for this TREK on its card, and Install opens
the warning first. Admin > Plugins reads cleaner: one row of alike facts per plugin
with each state that needs attention on its own line, type, status and order behind
one Filter button, and the containment notes in a dialog behind the shield in the
header. The row, the filter menu and the dialog moved into their own files.
2026-10-26 09:49:22 +01:00
Maurice d589da59f2 fix(wanderer): find the author of an account whose one actor wanderer expands as an object
PocketBase answers a back-relation with a single record as the object itself,
not a list, so the user lookup failed as an answer TREK did not understand and
every search with it.

Closes #2799
2026-10-26 01:03:35 +01:00
Allan Braun 69ff295184 fix(assistant): answer Brazilian Portuguese users in Portuguese, not Breton
TREK's code for Brazilian Portuguese is 'br', which Intl reads as Breton.
The assistant passed it straight to Intl.DisplayNames for the system
prompt's answer language and to the card formatters, so the model was told
to answer in Breton. Convert TREK codes to their BCP-47 tag with
getIntlLanguage before handing them to Intl.

Closes #2800
2026-10-26 00:04:56 +01:00
Maurice d07c17ded6 fix(assistant): finish an OpenRouter sign-in whose code came back after a second question mark
The return address already carries a query, so a code appended with ? instead
of & ended up inside the ai marker and the page did nothing, without a word.
2026-10-25 19:36:00 +01:00
Maurice 44509723fd fix(bookings): keep the review's map status within the complexity and strict-null budgets
The status hides itself for a stay linked to a trip place instead of each form
branching around it, and a lookup takes a missing name.
2026-10-25 18:59:55 +01:00
Maurice b80fdf27f2 fix(bookings): show where an imported stay lands on the map before it is saved
An imported hotel was looked up once, by its name and its address together.
A booking site's romanized address (Agoda for a stay in Japan) often matches
nothing and sank the hotel's name with it, so the place was created without
coordinates and silently never showed on the map.

The review now finds the stay right away (name and address, then the name,
then the address) and says under the address whether it is on the map and
where, or that it is not yet, with a way to search again. Picking an address
suggestion sets the spot by hand. The save uses that spot, falls back the same
way when there is none, and a stay saved without a spot says so in a toast.
Desktop dialog and phone sheet share the lookup through useReservationForm.
2026-10-25 18:59:55 +01:00
Maurice a0b7b4d196 chore(lint): lower the complexity baseline the route store move took down 2026-10-25 18:59:49 +01:00
Maurice aa24a9eff7 fix(map): keep routed roads on the device so a whole-trip route opens drawn
With "Show whole trip" on, every leg of every day went to the router one at a
time, a second apart, and the route cache lived in memory only, so each visit
started over: a trip of a month took 30 to 45 seconds before its route was
drawn, on every open.

Routes the router answered are now also kept on the device (Dexie v14,
routeCache by RouteCalculator's cache key, a month at most, the oldest give way
past 3000). A leg found there needs no request and no pause, so a trip whose
legs did not change opens with its roads at once; only new or changed legs are
routed. Plugin routes stay in memory only, since they depend on trip and day.
The cache moves out of RouteCalculator into routeStore.ts.

Closes #2794
2026-10-25 18:59:49 +01:00
Maurice de5417f9f0 style(i18n): wrap the wanderer no_author line in the CJK locales 2026-10-25 18:59:43 +01:00
Maurice a48dd7645f fix(wanderer): say so when wanderer names no author for the token's account
A token whose account has no local ActivityPub author was reported as
invalid_response, which the settings show as 'The answer did not come from
wanderer', although wanderer had answered fine. It now has its own code,
no_author, with a message in every language that says what is missing.
2026-10-25 18:59:43 +01:00
Maurice 217a576e16 fix(wanderer): log why an answer from wanderer was not understood
A failed wanderer call kept its reason in WandererError.detail (the field that
did not match, or what came instead of JSON), and the client comment said it
stays in the log, but nothing wrote it anywhere. The log only had the generic
'wanderer answered with something TREK does not understand', so neither an
admin nor we could tell what wanderer had sent. Every refusal now logs its
code, message and detail.
2026-10-25 18:59:43 +01:00
Maurice f6976d5cec fix(assistant): send OpenAI reasoning models on an API key to the Responses API
gpt-5.6-sol, gpt-6.1-sol and the other reasoning models refuse function tools
on Chat Completions, so every assistant message on such a model came back as
HTTP 400. An OpenAI connection whose model is from the o-series or GPT-5 and
later now goes through the Responses API, the client a ChatGPT plan already
uses, with the key as its bearer token and no token refresh on a 401. Older
models and the -chat variants stay on Chat Completions.

Closes #2792
2026-10-25 16:55:39 +01:00
Maurice c1c2bbf146 fix(assistant): replay an Anthropic answer that thought with its thinking blocks
Sonnet 5.5 and Opus 5.5 think by default and write what they say between tool
calls into thinking blocks, which they want back unchanged with the rest of
the turn. The client dropped those blocks (and their signatures) and rebuilt
the turn from its text and calls, so after a tool result the next step came
back empty and the assistant gave up with empty_answer. A turn that thought
is now replayed block by block like one that searched the web; the cache mark
before the question skips a thinking block, and replayed blocks are copies so
a mark never sticks to the stored answer.

Closes #2789
2026-10-25 16:49:46 +01:00
Maurice c05349f7c4 fix(docker): let dumb-init forward the stop signal in the shipped compose file
The compose file drops every capability and adds back CHOWN, SETUID and
SETGID. dumb-init runs as root and node as the node user, and signalling a
process of another user needs KILL, so docker stop never reached node and
always ended in SIGKILL (exit 137). KILL only reaches processes inside the
container's own PID namespace. CI starts the image with the same set and now
checks that docker stop exits 0 after a logged SIGTERM.

Closes #2790
2026-10-25 16:47:22 +01:00
Maurice a5342df648 fix(assistant): list the admin model switch among the routes a managed install withholds
The boot check pins every @ManagedForbidden route; the new
AiAdminController.setModel was missing, so the server refused to start.
2026-10-25 16:29:31 +01:00
Maurice f44b143371 fix(assistant): send a pasted ChatGPT callback once, and only for this sign-in
The server takes a sign-in only once. The clipboard read on return and a paste
of one's own could send the same address twice within one render, and an
address left over from an earlier attempt used up the one in progress, so the
real address came back as 'took too long'.
2026-10-25 16:29:31 +01:00
Maurice d911122a37 fix(assistant): let an admin switch the model of a shared ChatGPT plan
The model picker in Admin > Addons did nothing for a ChatGPT connection: the
only way to change the instance's model was saving the connection again, which
needs an API key a plan does not have. A PATCH /api/admin/ai/connection/model
now switches it on its own, to a model the provider offers.

Closes #2783
2026-10-25 16:29:31 +01:00
Maurice a69eee191c test(plugins): let the instance-settings fixture run on TREK 5
The fixture plugin declared trek >=4.0.0 <5.0.0, so since the version bump to
5.0.0 the host refuses to spawn it and INS-012 fails on dev.
2026-10-25 15:59:59 +01:00
Maurice 90f31d3f07 fix(assistant): give the phone home chat its own m-root so it is not see-through
The home chat outside a trip mounts beside MobileShell, so none of the --m-*
tokens it paints with resolved and the dashboard showed through the chat.
2026-10-25 15:59:59 +01:00
Maurice d4da05bbc9 docs(readme): name the container and allow plain-HTTP login in the quickstart
docker logs trek found nothing because the run command gave no --name, and
over plain HTTP the browser dropped the Secure session cookie, so the printed
admin login did not stick.
2026-10-25 15:41:45 +01:00
github-actions[bot] bd1de9e1d5 chore: bump version to 5.0.0 [skip ci] 2026-10-25 11:07:55 +00:00
Maurice c33eb5b593 refactor(files): the provider marks and the map that keys them live apart
The marks module exports components only, so fast refresh keeps it whole and react-refresh no longer counts it; the map by provider id moves to documentProviderIconMap.ts, which every caller now imports.
2026-10-25 11:24:36 +01:00
Maurice dcfbf89234 fix(files): the provider marks are reached through their map only
The 4.3.3 release card that imported the five marks one by one is gone, so they stay inside the module and knip no longer counts them as unused exports.
2026-10-25 11:09:41 +01:00
Maurice 6b6d971f07 fix: the CI findings on the 5.0 release notes and the demo fixes
The overview of a trip's tours decides for itself when it shows, which keeps the planner page hook inside its complexity and length limits, and adds nothing to the camera's key while there is no tour to frame. The elevation helper names its empty case for the strict type check, the self-host wording exemption for the retired Dawarich card goes, the day plan test reads the wrapping pills, and the new tests drop their non-null assertions and loops.
2026-10-25 11:03:03 +01:00
Maurice 9fc98b5d49 feat(notices): the release notes present 5.0.0
The update dialog now shows the 5.0.0 headliners, Tours, TREK Studio, the assistant and help on every screen in 27 languages, each with a drawing of its own, translated into every language TREK ships. The 4.3.3 cards and their drawings are gone.
2026-10-25 10:51:57 +01:00
Maurice daaa6c425d fix(notices): a release's notes show to whoever dismissed them on its prerelease
Dismissals were compared with the prerelease part dropped, so a notice closed on 5.0.0-pre.2 counted as closed on 5.0.0 and the final release showed nothing to anyone who had tried a prerelease. Versions now compare in full.
2026-10-25 10:51:57 +01:00
Maurice b33ef6bec2 feat(admin): the AI assistant addon carries a beta badge 2026-10-25 10:51:57 +01:00
Maurice eb9f6a3438 feat(tours): the Tours map shows the trip's tours while none is open
The map stayed empty until a tour was opened. It now draws every tour shown in the list, line and marker as on the plan, frames them, and opens a tour from its marker.
2026-10-25 10:51:57 +01:00
Maurice 7823ab1550 fix(collab): What's next times a hotel by its check-in when the stop has no time
The day a stay begins, a hotel stop without a time of its own read TBD; it now shows the stay's check-in, as the road trip already times it.
2026-10-25 10:51:56 +01:00
Maurice 47fdd3588c fix(collab): a link keeps its glyph until its favicon has really loaded
A host that never answered, or answered with a blank pixel, left the chip with an empty square.
2026-10-25 10:51:56 +01:00
Maurice a9ebd2d1ac fix(collab): a poll option with more than three voters shows how many more 2026-10-25 10:51:56 +01:00
Maurice 9c32b42256 fix(planner): a tour's facts in the day plan wrap instead of being cut off
A tour row carries its time, duration and distance; in the narrow day column the last of them disappeared behind the fade.
2026-10-25 10:51:56 +01:00
Maurice 26611b46e4 fix(tours): elevation model jitter no longer counts as ascent
Ascent and descent were the raw sum of every height difference, so a flat walk across Venice (highest point 16 m) read 121 m of climbing. Heights now count once they move 5 m from the last one that counted; the server's stored figures and the planner's live analysis share the one function.
2026-10-25 10:51:56 +01:00
Maurice e3eec8d6d1 fix(budget): a custom split in a foreign currency adds up to what was paid
Each member's amount was converted and rounded to a cent on its own, so four 34,000 ISK quarters of a 136,000 ISK bill came to 997.52 EUR against the 997.51 the payer was credited. The balances ended a few cents off zero and settle-up had no payment for them. A split whose amounts match the payers in the bill's own currency is now converted as one set by largest remainder, in the settlement and in the per-person summary alike.
2026-10-25 10:51:56 +01:00
Maurice 2d398568da fix(assistant): no status request while neither AI addon is on
Every page of an install with the assistant and AI parsing off asked /api/ai/status and logged the 404 the addon guard answers. The store asks only while an addon opens the route and the install is not managed, and one hook on the desktop window and both phone screens asks again when the assistant or AI parsing is switched.
2026-10-25 09:48:37 +01:00
Maurice 4051af58a7 fix(studio): a rail label wraps between words, never inside one
The rail gave its labels 48px and let them break anywhere, so Elements read Element and s, and nine languages broke a word at the default text size. Labels wrap only between words now, and the rail grows to its longest word from 64px.
2026-10-25 09:46:40 +01:00
Maurice 3681db6cd4 fix(collections): the collection map opens on all of its places
The map framed its first view for the browser window instead of its own box and then skipped the fit, so a list of cafes across Europe opened on northern France. It now fits the places in its own box with room for the label bar and the controls, fits again when the set of places changes, keeps a view the user moved, opens a single place at town level and keeps a list across the date line on its short arc.
2026-10-25 03:12:19 +00:00
Maurice 44cefc2fe2 docs(wiki): pictures for the 5.0 pages that had none, and captions that match
Tours, TREK Studio, Road Trip, Trip Settings, the notifications inbox, the phone layout, Help Center, Passkeys and the admin pages for settings, storage and notifications get pictures where the reader needs them, and the captions of the recaptured pictures say what they now show.
2026-10-25 02:10:39 +00:00
Maurice 176bfaefe0 docs(wiki): every picture of the app recaptured on 5.0 with the new demo content
The 74 screenshots of TREK itself show the 5.0 look and the three demo trips, journeys and collections, all at 1600x1000 in the light theme. The Portainer pictures stay as they were.
2026-10-25 02:10:39 +00:00
Maurice 2372dde716 fix(demo): journey and collab uploads are refused for the demo account too
Six upload routes refused the shared demo account in demo mode; journey photos, videos, gallery media and covers, collab note files and chat images did not, so any visitor could put images in front of every other one for the hour. They answer the same 403 now, checked in the handler after the upload is read, and a text-only chat message still goes through. The Demo Mode wiki page lists them, and says document sync is not covered.
2026-10-25 00:46:24 +00:00
Maurice 42ed858853 fix(demo): a demo instance keeps the files its baseline points at
The hourly reset puts only the database back, so a cover, photo, place image or trip file a visitor deleted in between was missing from the restored rows for good. On a demo instance StorageService.delete now leaves the content categories alone; the caches, the backups and the assistant's attachments are still deleted, and the orphan thumb sweep no longer counts a kept thumb as removed.
2026-10-25 00:46:24 +00:00
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
693 changed files with 13380 additions and 3290 deletions
+12 -1
View File
@@ -1161,7 +1161,7 @@ jobs:
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
--security-opt no-new-privileges:true \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
-v "$RUNNER_TEMP/trek/data:/app/data" \
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
@@ -1194,6 +1194,17 @@ jobs:
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|| { echo "::error::/ does not serve the client"; exit 1; }
# Same capabilities as the shipped compose file: dumb-init has to be able
# to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
- name: Stop gracefully
run: |
set -euo pipefail
docker stop -t 30 trek-ci >/dev/null
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
- name: Container log
if: always()
run: docker logs trek-ci 2>&1 || true
-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/
+5 -2
View File
@@ -234,12 +234,15 @@ the details.
## Get started in 30 seconds
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
-e COOKIE_SECURE=false \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
<div align="center">
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.4
version: 5.0.0
description: Minimal Helm chart for TREK app
appVersion: "4.3.4"
appVersion: "5.0.0"
+4 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.4",
"version": "5.0.0",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -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",
+5 -5
View File
@@ -1,16 +1,16 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-misused-promises": 849,
"no-console": 88,
"complexity": 221,
"complexity": 220,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
"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,
@@ -20,7 +20,7 @@
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 45,
"react-refresh/only-export-components": 44,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
@@ -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,
+17 -2
View File
@@ -65,7 +65,7 @@
},
{
"feature": "AI assistant chat",
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
},
@@ -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",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"admin.ts": 126,
"admin.ts": 112,
"atlas.ts": 9,
"backup.ts": 10,
"budget.ts": 36,
-1
View File
@@ -150,7 +150,6 @@
"src/pages/resetPassword/useResetPassword.ts",
"src/pages/settings/useSettings.ts",
"src/pages/sharedTrip/useSharedTrip.ts",
"src/pages/tripPlanner/useBookingEdits.ts",
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
"src/pages/tripPlanner/usePlannerDialogs.ts",
"src/pages/tripPlanner/useTripPlanner.ts",
-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 -3
View File
@@ -1,5 +1,5 @@
{
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/plugins/pluginMenu.ts": 2,
"components/Budget/BudgetPanelMemberChips.tsx": 6,
"components/Budget/CostsPanel.tsx": 3,
"components/Budget/ReceiptPreviewModal.tsx": 2,
@@ -59,8 +59,6 @@
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
"components/SystemNotices/releaseNotice.css": 2,
"components/Vacay/VacayCalendar.tsx": 3,
"components/Vacay/VacayMonthCard.tsx": 7,
"components/Vacay/VacaySettings.tsx": 1,
+1
View File
@@ -1,4 +1,5 @@
{
"components/Collections/CollectionMap.tsx": 1,
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
+4 -4
View File
@@ -2,13 +2,13 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"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/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/Map/RouteCalculator.ts": 1063,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
+2 -4
View File
@@ -1,7 +1,7 @@
{
"App.tsx": 11,
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/DefaultUserSettingsTab.tsx": 1,
"components/Admin/plugins/pluginMenu.ts": 2,
"components/Atlas/AtlasCountryPlaces.tsx": 6,
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
"components/Budget/BudgetPanel.helpers.ts": 2,
@@ -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,
@@ -106,7 +105,6 @@
"components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5,
"components/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
"components/Todo/TodoListPanel.tsx": 6,
@@ -207,7 +205,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,
+2
View File
@@ -229,6 +229,8 @@ export const aiAdminApi = {
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
setModel: (model: string): Promise<AiAdminConnectionResponse> =>
apiClient.patch('/admin/ai/connection/model', { model }).then((r) => r.data),
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
+1
View File
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true })
})
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
+2 -2
View File
@@ -574,9 +574,9 @@ export const adminApi = {
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
pluginInstall: (id: string, opts?: Omit<PluginInstallRequest, 'id'>) =>
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginActivate: (id: string, consent?: boolean, accept?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, { consent: consent || undefined, acceptTrekRange: accept || undefined }).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself.
+1 -1
View File
@@ -5,7 +5,7 @@ import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, I
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
+8 -2
View File
@@ -19,6 +19,7 @@ export default function AddonSubRow({
icon,
title,
description,
badge,
enabled,
onToggle,
}: {
@@ -26,6 +27,8 @@ export default function AddonSubRow({
icon?: ReactNode
title: string
description?: string
/** A small pill after the title, e.g. the beta mark on the assistant. */
badge?: ReactNode
enabled: boolean
onToggle: () => void
}) {
@@ -34,8 +37,11 @@ export default function AddonSubRow({
{icon
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
: <span className="w-6 flex-none" aria-hidden />}
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
{title}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className={`min-w-0 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
{title}
</span>
{badge}
</span>
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
</li>
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
fireEvent.click(await screen.findByText('Discover'))
}
it('blocks Install when no published version runs on this TREK, and says why', async () => {
it('marks a plugin no published version runs on, and still lets the admin install it', async () => {
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
expect(btn).toBeDisabled()
expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
})
it('offers the newest version that DOES run here rather than a dead button', async () => {
@@ -549,9 +549,11 @@ function panelRoot(container: HTMLElement): HTMLElement {
}
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
async function pickFilter(trigger: RegExp, option: RegExp) {
fireEvent.click(await screen.findByTitle(trigger))
fireEvent.click(await screen.findByRole('button', { name: option }))
/** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
async function pickFilter(option: RegExp) {
if ((await screen.findByRole('button', { name: /^Filter/ })).getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton())
fireEvent.click(await screen.findByRole('menuitemradio', { name: option }))
}
/** The enable toggle of a named installed row. */
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
await pickFilter(/^Type: All types$/, /^Widget$/)
await pickFilter(/^Widget$/)
expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('Countdown')).toBeInTheDocument()
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
})
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('OnOne')
await pickFilter(/^Status: All$/, /^Off$/)
await pickFilter(/^Off$/)
expect(await screen.findByText('OffOne')).toBeInTheDocument()
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
await pickFilter(/^Status: Off$/, /^Error$/)
await pickFilter(/^Error$/)
expect(await screen.findByText('BadOne')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument()
await pickFilter(/^Status: Error$/, /^Update available$/)
await pickFilter(/^Update available$/)
expect(await screen.findByText('OnOne')).toBeInTheDocument()
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
})
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
await pickFilter(/^Sort: Name$/, /^Updates first$/)
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
await pickFilter(/^Updates first$/)
expect(filterButton()).toHaveTextContent('Updates first')
await clickDiscover()
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
@@ -1663,15 +1664,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
})
})
describe('AdminPluginsPanel — security footer', () => {
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
describe('AdminPluginsPanel — security dialog', () => {
it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
panelWith([])
render(<AdminPluginsPanel />)
await screen.findByText('No plugins installed yet.')
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' }))
expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument()
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
@@ -1700,9 +1702,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
]
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
await user.click(screen.getByTitle(filter))
await user.click(screen.getByRole('button', { name: option }))
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
await user.click(screen.getByRole('menuitemradio', { name: option }))
}
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
@@ -1734,7 +1736,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Type:/, /^Widget$/)
await pickFilter(user, /^Widget$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Status:/, /^Active$/)
await pickFilter(user, /^Active$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Off$/)
await pickFilter(user, /^Off$/)
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Error$/)
await pickFilter(user, /^Error$/)
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
})
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
await pickFilter(user, /^Sort:/, /^Updates first$/)
await pickFilter(user, /^Updates first$/)
await waitFor(() => {
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
expect(names[0]).toBe('Gamma Page')
})
await pickFilter(user, /^Status:/, /^Update available$/)
await pickFilter(user, /^Update available$/)
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
})
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Sort:/, /^Updates first$/)
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
await pickFilter(user, /^Updates first$/)
expect(filterButton()).toHaveTextContent('Updates first')
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
@@ -1802,19 +1803,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Old Tool')
await pickFilter(user, /^Sort:/, /^Most downloads$/)
await pickFilter(user, /^Most downloads$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Recently updated$/)
await pickFilter(user, /^Recently updated$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Name$/)
await pickFilter(user, /^Name$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Mapper')
await pickFilter(user, /^Type:/, /^Page$/)
await pickFilter(user, /^Page$/)
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
expect(screen.getByText('Poster')).toBeInTheDocument()
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
const { container } = render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTitle(/^Type:/))
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
await user.click(filterButton())
expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
fireEvent.click(layer)
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument())
})
})
@@ -2004,12 +2005,11 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
await screen.findByText('Alpha Widget')
await openDiscover(user)
await user.click(screen.getByTitle(/^Sort:/))
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
await user.click(filterButton())
await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
expect(filterButton()).toHaveTextContent('Most downloads')
await user.click(screen.getByRole('tab', { name: /Installed/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
@@ -2026,9 +2026,8 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
expect(offer).not.toBeDisabled()
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
expect(dead).toBeDisabled()
expect(dead.getAttribute('title')).toBeTruthy()
// Nothing fits: no dead button any more, but a card that says so before anything installs.
expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
})
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
@@ -2556,7 +2555,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
})
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => {
let posted: unknown = null
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
@@ -2566,7 +2565,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
withToast()
fireEvent.click(await screen.findByText('Discover'))
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
const btn = await screen.findByRole('button', { name: /^install$/i })
expect(btn).toBeEnabled()
fireEvent.click(btn)
@@ -2585,7 +2584,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
withToast()
fireEvent.click(await screen.findByText('Discover'))
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
+33 -384
View File
@@ -1,10 +1,9 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useState, type ReactNode } from 'react'
import {
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2,
ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, CircleDot, Database,
Globe, History, Puzzle, Bot, Loader2,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { useInstanceSettings } from './useInstanceSettings'
@@ -23,15 +22,17 @@ import {
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
} from '../Settings/settingsKit'
import { POPOVER } from '../Packing/packingPopoverStyles'
import {
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount,
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel,
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey,
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch,
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
installOffer, signatureBlockView,
type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
type T, type VersionInfo, type VersionMismatch,
} from './plugins/pluginModel'
import { usePluginDetail } from './plugins/usePluginDetail'
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -42,31 +43,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
* tile; the security section at the bottom explains the model honestly.
*/
// Runtime health → dot colour on the icon tile.
const HEALTH: Record<string, string> = {
active: 'bg-success',
starting: 'bg-info animate-pulse',
error: 'bg-danger',
inactive: 'bg-content-faint/60',
disabled: 'bg-warning',
incompatible: 'bg-warning',
}
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
/** The eyebrow over a block, the size the dialogs' sections use. */
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
/** A capability or dependency chip on an installed row. */
const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium'
const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary'
const CHIP_NET = 'border-transparent bg-info-soft text-info'
const CHIP_WARN = 'border-transparent bg-warning-soft text-warning'
/** A small action inside a row or a card: the kit's button, a size down. */
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
/** A line of state under a row's name: a refused update, a paused one, an error. */
const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5'
/** A pill that explains itself on hover. */
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
return (
@@ -76,19 +59,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
)
}
function ReviewedBadge({ t }: { t: T }) {
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
}
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
function SideloadedBadge({ t }: { t: T }) {
return (
<HintPill hint={t('admin.plugins.sideloadedHint')}>
<StatusPill tone="warning" icon={<UploadCloud size={11} />}>{t('admin.plugins.sideloaded')}</StatusPill>
</HintPill>
)
}
/**
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
*
@@ -113,15 +83,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
)
}
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
function DevLinkBadge({ t }: { t: T }) {
return (
<HintPill hint={t('admin.plugins.devLinkHint')}>
<StatusPill icon={<Link2 size={11} />}>{t('admin.plugins.devLinkBadge')}</StatusPill>
</HintPill>
)
}
function TypeBadge({ type, t }: { type: string; t: T }) {
return (
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
@@ -145,6 +106,7 @@ export default function AdminPluginsPanel() {
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
uploadPlugin, versionPicker, view,
} = usePluginsAdmin()
const [securityOpen, setSecurityOpen] = useState(false)
return (
<div className="relative mb-24 sm:mb-0"
@@ -181,6 +143,9 @@ export default function AdminPluginsPanel() {
) : undefined}
action={ready ? (
<>
<BandButton label={t('admin.plugins.security.short')} onClick={() => setSecurityOpen(true)}>
<ShieldCheck size={15} strokeWidth={2} />
</BandButton>
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
<UploadCloud size={15} strokeWidth={2} />
</BandButton>
@@ -242,31 +207,9 @@ export default function AdminPluginsPanel() {
/>
</label>
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
options={[
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
['trip-page', t('admin.plugins.type.trip-page')],
]}
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
onPick={v => setTypeFilter(v as TypeFilter)} />
{view === 'installed' && (
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
options={[
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
]}
valueLabel={statusLabel(statusFilter, t)}
onPick={v => setStatusFilter(v as StatusFilter)} />
)}
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
options={view === 'discover'
? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
: [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
valueLabel={sortLabel(sort, t)}
onPick={v => setSort(v as SortKey)} />
<PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
menu={menu} setMenu={setMenu} />
</div>
{view === 'discover' ? (
@@ -299,7 +242,7 @@ export default function AdminPluginsPanel() {
) : (
<SettingRows>
{shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
<PluginInstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
newerIncompatible={newerIncompatible(p)}
blocked={blockIsCurrent(p, latest[p.id])}
@@ -320,7 +263,7 @@ export default function AdminPluginsPanel() {
)}
</SettingsCard>
<SecurityInfo t={t} />
{securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
{/* Registry detail dialog */}
{detailFor && (
@@ -438,268 +381,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: (
)
}
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
function useFollowTrigger(open: boolean) {
const [, reanchor] = useState(0)
useEffect(() => {
if (!open) return
const onMove = () => reanchor(n => n + 1)
window.addEventListener('scroll', onMove, true)
window.addEventListener('resize', onMove)
return () => {
window.removeEventListener('scroll', onMove, true)
window.removeEventListener('resize', onMove)
}
}, [open])
}
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
id: string; label: string; valueLabel: string
options: Array<[string, string]>; onPick: (v: string) => void; value: string
menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode
}) {
const open = menu === id
const active = value !== options[0]?.[0]
const btnRef = useRef<HTMLButtonElement>(null)
// Portaled like the row menu: the card around the toolbar clips what overflows it.
useFollowTrigger(open)
return (
<div className="relative flex-none">
<button type="button" ref={btnRef} onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
style={fs(12.5, 'body')}>
<span className="flex-none text-content-faint">{icon}</span>
<span>{label}: </span>
<span className="font-semibold text-content">{valueLabel}</span>
<ChevronDown size={13} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && createPortal(
<div className="min-w-[200px] max-w-[calc(100vw-2rem)]" style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}>
{options.map(([v, lbl]) => (
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
</button>
))}
</div>,
document.body,
)}
</div>
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
const deps = deriveDeps(p, t)
const menuOpen = menu === `row:${p.id}`
const menuBtnRef = useRef<HTMLButtonElement>(null)
const working = busy === p.id
const on = p.enabled === 1 && p.status !== 'error'
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
// Re-anchor while the page moves under it.
useFollowTrigger(menuOpen)
const hostCount = p.egressHostCount ?? 0
return (
<div className={`group relative flex items-start gap-3 px-3.5 py-3 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}>
<div className="relative mt-0.5 flex-none">
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
</span>
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
</Tooltip>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>{p.name}</span>
{p.version && <span className="font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>v{p.version}</span>}
{p.reviewed_at && <ReviewedBadge t={t} />}
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
</div>
{p.description && <p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>{p.description}</p>}
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
its own row state, not an error state, and it persists until it is resolved
rather than dying with the toast that first reported it. */}
{blocked && p.updateBlock && (
<div className={`${ROW_NOTE} text-warning`} style={fs(11.5)}>
<ShieldAlert size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
<button type="button" onClick={onReviewBlock}
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80">
{t('admin.plugins.reviewBlock')}
</button>
</div>
)}
{/* A newer version this TREK can't run — informational, never a button: the fix
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
{newerIncompatible && (
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
<Info size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
</div>
)}
{/* Held: the admin deliberately installed a non-latest version, so updates are
paused rather than nagged — resuming is one click, right where the pause shows. */}
{p.updateHold && (
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
<PauseCircle size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
<button type="button" onClick={onResume} disabled={working}
className="flex-none font-semibold text-content-secondary underline underline-offset-2 hover:opacity-80 disabled:opacity-50">
{t('admin.plugins.resumeUpdates')}
</button>
</div>
)}
{p.status === 'error' && p.last_error ? (
<div className={`${ROW_NOTE} text-danger`} style={fs(11.5)}>
<AlertTriangle size={13} className="flex-none" /><span className="truncate">{p.last_error}</span>
</div>
) : (caps.length > 0 || p.operatorEgress) && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{caps.map((c, i) => (
<span key={i} className={`${CHIP} ${c.net ? CHIP_NET : CHIP_NEUTRAL}`} style={fs(11)}>
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} /><span className="truncate">{c.label}</span>
</span>
))}
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
Actionable, and warning-toned until at least one host exists, because
until then the plugin cannot reach anything and looks silently broken. */}
{p.operatorEgress && (
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
<button type="button" onClick={onEgress}
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
<Globe size={12} />
{hostCount > 0
? t('admin.plugins.allowedHosts.count', { n: hostCount })
: t('admin.plugins.allowedHosts.add')}
</button>
</Tooltip>
)}
</div>
)}
{deps.length > 0 && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{deps.map((d, i) => (
<span key={i} className={`${CHIP} ${d.blocked || d.warn ? CHIP_WARN : CHIP_NEUTRAL}`} style={fs(11)}>
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} /><span className="truncate">{d.label}</span>
</span>
))}
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 self-center">
{hasUpdate && (
<button type="button" onClick={onUpdate} disabled={working}
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
style={fs(11.5)}>
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
)}
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
<div className="relative">
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
<MoreHorizontal size={17} />
</button>
</Tooltip>
{menuOpen && createPortal(
<div data-testid={`plugin-row-menu-${p.id}`} style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
className="min-w-[200px]">
{p.enabled === 1 && (
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)}
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
never be invited to widen egress for a plugin that didn't ask for it
(same rule the row's egress chip follows). */}
{p.operatorEgress && (
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
<span className="flex w-4 flex-none justify-center text-content-muted"><Github size={14} /></span> {t('admin.plugins.sourceRepo')}
</a>
<a href={`https://github.com/${p.source_repo}/issues`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
<span className="flex w-4 flex-none justify-center text-content-muted"><CircleDot size={14} /></span> {t('admin.plugins.reportIssue')}
</a>
</>
)}
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
</div>,
document.body,
)}
</div>
</div>
</div>
)
}
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
const ROW_MENU_MAX_H = 260
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
const r = trigger?.getBoundingClientRect()
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
const spaceBelow = window.innerHeight - r.bottom
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
return {
position: 'fixed',
right: Math.max(8, window.innerWidth - r.right),
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
zIndex: 100,
}
}
/** One entry of a popover menu, in the packing popovers' shape. */
const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'
function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button type="button" onClick={onClick}
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>{icon}</span> {label}
</button>
)
}
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
const [failed, setFailed] = useState(false)
return (
@@ -751,6 +432,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
</span>
)}
{/* Says up front that this one has not been confirmed for the TREK it would run on,
so "Install" never reads as a plain install; the dialog explains the risk. */}
{!installed && offer.warn && (
// Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot.
<span className="absolute start-2.5 top-2.5 inline-flex max-w-[62%] items-center gap-1 rounded-full bg-warning px-2 py-[3px] font-geist font-semibold text-[color:var(--surface-card)] shadow-md"
style={fs(11)}>
<AlertTriangle size={11} className="flex-none" />
<span className="truncate">{t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })}</span>
</span>
)}
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
</span>
@@ -829,8 +520,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
* component rather than ConfirmDialog because the notice mode has no cancel button.
*/
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
}) {
return (
<DialogShell
@@ -844,7 +535,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
{confirm ? (
<>
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={onConfirm}>{t('admin.plugins.installAnyway')}</DialogButton>
<DialogButton variant="primary" onClick={onConfirm}>{confirmLabel}</DialogButton>
</>
) : (
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
@@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
</DialogShell>
)
}
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
// panel that lays out how plugins are contained, the limits, and the worst case.
function SecurityInfo({ t }: { t: T }) {
const [open, setOpen] = useState(false)
const sections: Array<[string, string]> = [
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
]
return (
<SettingsCard
icon={Lock}
title={(
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
<span className="truncate">{t('admin.plugins.security.title')}</span>
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
)}
>
<div className="flex items-start gap-2.5">
<ShieldCheck size={14} className="mt-0.5 flex-none text-content-faint" />
<SettingsHint>{t('admin.plugins.reviewedMeaning')}</SettingsHint>
</div>
{open && (
<div className="grid gap-2.5 sm:grid-cols-2">
{sections.map(([h, b]) => (
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>{t(h as never)}</h4>
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>{t(b as never)}</p>
</div>
))}
</div>
)}
</SettingsCard>
)
}
@@ -66,6 +66,7 @@ export default function AiAddonTile({
icon={<Icon size={14} />}
title={label.name}
description={label.description}
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
enabled={addon.enabled}
onToggle={() => onToggle(addon)}
/>
@@ -93,3 +94,16 @@ export default function AiAddonTile({
</article>
);
}
/** The assistant is still young; its switch says so with a small accent pill. */
function BetaBadge({ label }: { label: string }): React.ReactElement {
return (
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-[7px] py-[2px] font-geist font-bold uppercase leading-none tracking-[.08em] text-accent-text shadow-sm"
style={fs(9)}
>
<Sparkles size={9} strokeWidth={2.6} aria-hidden />
{label}
</span>
);
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import { openrouterReturnCode } from '../../Settings/ai/aiConnectModel';
import type { Addon } from './addonModel';
import { usesConnection } from './aiAddons';
@@ -11,7 +12,7 @@ import { usesConnection } from './aiAddons';
*/
export function useAiAddonTile(aiAddons: Addon[]) {
const [params] = useSearchParams();
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
const [open, setOpen] = useState(() => openrouterReturnCode(params) !== undefined);
const canSetUp = usesConnection(aiAddons);
return {
enabledCount: aiAddons.filter((a) => a.enabled).length,
@@ -1,11 +1,13 @@
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-008: the AI assistant's settings and connection for everyone.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { MASKED_SETTING_VALUE } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
@@ -14,6 +16,7 @@ const repo = vi.hoisted(() => ({
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
setAdminModel: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
@@ -59,6 +62,8 @@ async function renderLoaded() {
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
// The settings show while the assistant is on, and only then is its status asked for.
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
@@ -153,6 +158,29 @@ describe('useAiAdminConfig', () => {
});
});
it("FE-HOOK-AI-ADMIN-008: a shared ChatGPT plan's model is switched on its own, and the answer shows (#2783)", async () => {
const plan = (model: string): AiAdminConnectionResponse => ({
connection: {
scope: 'instance',
kind: 'chatgpt',
model,
hint: null,
status: 'ok',
host: 'api.openai.com',
baseUrl: null,
},
prefill: null,
});
repo.adminConnection.mockResolvedValue(plan('gpt-6.1-sol'));
repo.setAdminModel.mockResolvedValue(plan('gpt-6.1-sol-mini'));
const { result } = await renderLoaded();
await act(async () => result.current.instance.setModel('gpt-6.1-sol-mini'));
await waitFor(() => expect(repo.setAdminModel).toHaveBeenCalledWith('gpt-6.1-sol-mini'));
expect(repo.saveAdminConnection).not.toHaveBeenCalled();
await waitFor(() => expect(result.current.instance.live?.model).toBe('gpt-6.1-sol-mini'));
expect(toast.success).toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
const { result } = await renderLoaded();
@@ -124,9 +124,16 @@ function useInstanceConnection(onChanged: () => void) {
}
};
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
/**
* Another model on the same connection: the stored key is kept by sending the mask in its place.
* A shared ChatGPT plan has a sign-in, not a key, so its model is switched on its own (#2783).
*/
const setModel = (model: string) => {
if (!live || live.kind === 'chatgpt') return;
if (!live) return;
if (live.kind === 'chatgpt') {
void act(() => aiConnectionsRepo.setAdminModel(model), 'common.saved');
return;
}
const request: AiInstanceConnectionRequest =
live.kind === 'custom'
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
@@ -0,0 +1,167 @@
import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { POPOVER } from '../../Packing/packingPopoverStyles';
import { fs } from '../../shared/DialogShell';
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel';
const MENU_ID = 'filters';
interface Section {
title: string;
value: string;
options: Array<[string, string]>;
onPick: (v: string) => void;
}
/**
* Type, status and order of the plugin list behind one button instead of three, so the
* toolbar stays search plus one control. The button counts the filters that narrow the
* list (the order does not narrow it) and names the order it is in.
*/
export default function PluginFilterMenu({
t,
view,
typeFilter,
setTypeFilter,
statusFilter,
setStatusFilter,
sort,
setSort,
menu,
setMenu,
}: {
t: T;
view: 'installed' | 'discover';
typeFilter: TypeFilter;
setTypeFilter: (v: TypeFilter) => void;
statusFilter: StatusFilter;
setStatusFilter: (v: StatusFilter) => void;
sort: SortKey;
setSort: (v: SortKey) => void;
menu: string | null;
setMenu: (v: string | null) => void;
}) {
const open = menu === MENU_ID;
const btnRef = useRef<HTMLButtonElement>(null);
useFollowTrigger(open);
const sections: Section[] = [
{
title: t('admin.plugins.filterType'),
value: typeFilter,
options: [
['all', t('admin.plugins.allTypes')],
['widget', t('admin.plugins.type.widget')],
['integration', t('admin.plugins.type.integration')],
['page', t('admin.plugins.type.page')],
['trip-page', t('admin.plugins.type.trip-page')],
],
onPick: (v) => setTypeFilter(v as TypeFilter),
},
...(view === 'installed'
? [
{
title: t('admin.plugins.filterStatus'),
value: statusFilter,
options: [
['all', t('admin.plugins.allStatuses')],
['on', t('admin.plugins.status.active')],
['off', t('admin.plugins.stateOff')],
['update', t('admin.plugins.filterUpdate')],
['err', t('admin.plugins.status.error')],
] as Array<[string, string]>,
onPick: (v: string) => setStatusFilter(v as StatusFilter),
},
]
: []),
{
title: t('admin.plugins.sortBy'),
value: sort,
options:
view === 'discover'
? [
['name', t('admin.plugins.sortName')],
['recent', t('admin.plugins.sortRecent')],
['downloads', t('admin.plugins.sortDownloads')],
]
: [
['name', t('admin.plugins.sortName')],
['recent', t('admin.plugins.sortRecent')],
['updates', t('admin.plugins.sortUpdates')],
],
onPick: (v) => setSort(v as SortKey),
},
];
const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0);
return (
<div className="relative flex-none">
<button
type="button"
ref={btnRef}
onClick={() => setMenu(open ? null : MENU_ID)}
aria-expanded={open}
aria-haspopup="menu"
className={`inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
narrowing ? 'border-[color:var(--text-primary)]' : 'border-edge'
}`}
style={fs(12.5, 'body')}
>
<SlidersHorizontal size={14} className="flex-none text-content-faint" />
<span className="font-semibold text-content">{t('admin.plugins.filters')}</span>
{narrowing > 0 && (
<span
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1.5 font-geist font-bold tabular-nums text-accent-text"
style={fs(10.5)}
>
{narrowing}
</span>
)}
<span className="text-content-faint">{sortLabel(sort, t)}</span>
<ChevronDown
size={13}
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open &&
createPortal(
<div
role="menu"
className="w-[240px] max-w-[calc(100vw-2rem)]"
style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}
>
{sections.map((s, i) => (
<div key={s.title} className={i > 0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}>
<p
className="m-0 px-2.5 pb-1 pt-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(10)}
>
{s.title}
</p>
{s.options.map(([v, label]) => (
<button
type="button"
role="menuitemradio"
aria-checked={s.value === v}
key={v}
onClick={() => s.onPick(v)}
className={`${MENU_ITEM} ${s.value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
<Check
size={14}
className={`flex-none text-content-muted ${s.value === v ? 'opacity-100' : 'opacity-0'}`}
/>
</button>
))}
</div>
))}
</div>,
document.body
)}
</div>
);
}
@@ -0,0 +1,426 @@
import {
AlertTriangle,
ArrowUpCircle,
Bug,
CircleDot,
Github,
Globe,
History,
Info,
KeyRound,
Link2,
MoreHorizontal,
PauseCircle,
RotateCw,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Trash2,
UploadCloud,
} from 'lucide-react';
import { useRef, type ComponentType, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { POPOVER } from '../../Packing/packingPopoverStyles';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { fs } from '../../shared/DialogShell';
import PluginIcon from '../../shared/PluginIcon';
import { Tooltip } from '../../shared/Tooltip';
import { deriveCaps } from '../pluginCaps';
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel';
// Runtime health → dot colour on the icon tile.
const HEALTH: Record<string, string> = {
active: 'bg-success',
starting: 'bg-info animate-pulse',
error: 'bg-danger',
inactive: 'bg-content-faint/60',
disabled: 'bg-warning',
incompatible: 'bg-warning',
};
/** Every fact about a plugin wears the same chip; only its icon carries a colour. */
const FACT =
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary';
type Tone = 'warning' | 'danger' | 'neutral';
const NOTICE_LOOK: Record<Tone, string> = {
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
neutral: 'bg-surface-secondary text-content-muted',
};
/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */
interface Notice {
key: string;
tone: Tone;
icon: ComponentType<{ size?: number; className?: string }>;
text: string;
action?: { label: string; onClick: () => void; disabled?: boolean };
}
function Fact({
icon,
iconClass = 'text-content-muted',
hint,
children,
}: {
icon: ReactNode;
iconClass?: string;
hint?: string;
children: ReactNode;
}) {
const chip = (
<span className={FACT} style={fs(11)}>
<span className={`flex flex-none ${iconClass}`}>{icon}</span>
<span className="truncate">{children}</span>
</span>
);
return hint ? (
<Tooltip label={hint}>
<span className="inline-flex max-w-full">{chip}</span>
</Tooltip>
) : (
chip
);
}
function NoticeLine({ n }: { n: Notice }) {
return (
<div
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-1.5 ${NOTICE_LOOK[n.tone]}`}
style={fs(11.5)}
>
<n.icon size={13} className="flex-none" />
<span className="min-w-0 flex-1 truncate">{n.text}</span>
{n.action && (
<button
type="button"
onClick={n.action.onClick}
disabled={n.action.disabled}
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80 disabled:opacity-50"
>
{n.action.label}
</button>
)}
</div>
);
}
function MenuItem({
icon,
label,
onClick,
danger,
}: {
icon: ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}
>
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>
{icon}
</span>{' '}
{label}
</button>
);
}
interface InstalledRowProps {
p: PluginRow;
t: T;
busy: string | null;
menu: string | null;
setMenu: (v: string | null) => void;
hasUpdate: boolean;
latestVer?: string;
newerIncompatible: { version: string; range: string } | null;
blocked: boolean;
onToggle: () => void;
onUpdate: () => void;
onRestart: () => void;
onChangeVersion: () => void;
onResume: () => void;
onReviewBlock: () => void;
onErrors: () => void;
onEgress: () => void;
onSettings: () => void;
onUninstall: () => void;
}
/**
* One installed plugin, read top to bottom: its name and version, what it does, the
* facts about it as one row of alike chips (what it adds, whether it was reviewed and
* signed, where it may connect), then each state that needs attention on a line of its
* own with the action that resolves it. The controls stay on the right.
*/
export default function PluginInstalledRow(props: InstalledRowProps) {
const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props;
const caps = deriveCaps(
parseJson<string[]>(p.permissions, []),
parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
t
);
const deps = deriveDeps(p, t);
const menuOpen = menu === `row:${p.id}`;
const menuBtnRef = useRef<HTMLButtonElement>(null);
const working = busy === p.id;
const hostCount = p.egressHostCount ?? 0;
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
useFollowTrigger(menuOpen);
const notices: Notice[] = [];
if (p.status === 'error' && p.last_error) {
notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error });
}
// A refused update leaves a WORKING plugin pinned at its old version: its own state,
// kept until resolved rather than dying with the toast that first reported it.
if (blocked && p.updateBlock) {
notices.push({
key: 'block',
tone: 'warning',
icon: ShieldAlert,
text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }),
action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock },
});
}
for (const [i, d] of deps.entries()) {
if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label });
}
// Held: the admin deliberately installed a non-latest version, so updates are paused
// rather than nagged — resuming is one click, right where the pause shows.
if (p.updateHold) {
notices.push({
key: 'hold',
tone: 'neutral',
icon: PauseCircle,
text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }),
action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working },
});
}
// A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade.
if (newerIncompatible) {
notices.push({
key: 'newer',
tone: 'neutral',
icon: Info,
text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }),
});
}
return (
<div
className={`group relative flex items-start gap-3 px-3.5 py-3.5 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}
>
<div className="relative mt-0.5 flex-none">
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
</span>
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
<span
className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
/>
</Tooltip>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>
{p.name}
</span>
{p.version && (
<span className="flex-none font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>
v{p.version}
</span>
)}
</div>
{p.description && (
<p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>
{p.description}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{caps.map((c, i) => (
<Fact key={i} icon={<c.icon size={12} />} iconClass={c.net ? 'text-info' : 'text-content-muted'}>
{c.label}
</Fact>
))}
{deps
.filter((d) => !d.blocked && !d.warn)
.map((d, i) => (
<Fact key={`d${i}`} icon={<d.icon size={12} />}>
{d.label}
</Fact>
))}
{p.reviewed_at && (
<Fact icon={<ShieldCheck size={12} />} iconClass="text-success">
{t('admin.plugins.reviewed')}
</Fact>
)}
{p.source_repo === 'local:upload' && (
<Fact icon={<UploadCloud size={12} />} iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}>
{t('admin.plugins.sideloaded')}
</Fact>
)}
{p.source_repo === 'local:link' && (
<Fact icon={<Link2 size={12} />} hint={t('admin.plugins.devLinkHint')}>
{t('admin.plugins.devLinkBadge')}
</Fact>
)}
{/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */}
{isRegistrySourced(p.source_repo) &&
(p.signed ? (
<Fact icon={<KeyRound size={12} />} hint={t('admin.plugins.signedHint')}>
{t('admin.plugins.signed')}
</Fact>
) : (
<Fact icon={<ShieldAlert size={12} />} iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}>
{t('admin.plugins.unsigned')}
</Fact>
))}
{/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds
its host, and until one exists the plugin reaches nothing, so it asks for it. */}
{p.operatorEgress && (
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
<button type="button" onClick={props.onEgress} className={`${FACT} hover:opacity-80`} style={fs(11)}>
<Globe size={12} className={hostCount > 0 ? 'text-info' : 'text-warning'} />
{hostCount > 0
? t('admin.plugins.allowedHosts.count', { n: hostCount })
: t('admin.plugins.allowedHosts.add')}
</button>
</Tooltip>
)}
</div>
{notices.length > 0 && (
<div className="mt-2.5 flex flex-col gap-1.5">
{notices.map((n) => (
<NoticeLine key={n.key} n={n} />
))}
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 self-center">
{hasUpdate && (
<button
type="button"
onClick={props.onUpdate}
disabled={working}
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
style={fs(11.5)}
>
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
)}
<span
className={`min-w-[42px] text-end font-medium ${p.enabled === 1 ? 'text-content-secondary' : 'text-content-faint'}`}
style={fs(12)}
>
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={props.onToggle} />
<div className="relative">
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
<button
type="button"
ref={menuBtnRef}
data-testid={`plugin-row-menu-btn-${p.id}`}
onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
aria-expanded={menuOpen}
aria-label={t('admin.table.actions')}
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
>
<MoreHorizontal size={17} />
</button>
</Tooltip>
{menuOpen &&
createPortal(
<div
data-testid={`plugin-row-menu-${p.id}`}
style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
className="min-w-[200px]"
>
{p.enabled === 1 && (
<MenuItem
icon={<RotateCw size={14} />}
label={t('admin.plugins.restart')}
onClick={props.onRestart}
/>
)}
{/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem
icon={<SlidersHorizontal size={14} />}
label={t('admin.plugins.instanceSettings')}
onClick={props.onSettings}
/>
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={props.onErrors} />
{/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */}
{p.operatorEgress && (
<MenuItem
icon={<Globe size={14} />}
label={t('admin.plugins.allowedHosts')}
onClick={props.onEgress}
/>
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem
icon={<History size={14} />}
label={t('admin.plugins.changeVersion')}
onClick={props.onChangeVersion}
/>
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a
href={`https://github.com/${p.source_repo}`}
target="_blank"
rel="noreferrer"
onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`}
style={fs(12.5, 'body')}
>
<span className="flex w-4 flex-none justify-center text-content-muted">
<Github size={14} />
</span>{' '}
{t('admin.plugins.sourceRepo')}
</a>
<a
href={`https://github.com/${p.source_repo}/issues`}
target="_blank"
rel="noreferrer"
onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`}
style={fs(12.5, 'body')}
>
<span className="flex w-4 flex-none justify-center text-content-muted">
<CircleDot size={14} />
</span>{' '}
{t('admin.plugins.reportIssue')}
</a>
</>
)}
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={props.onUninstall} />
</div>,
document.body
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { ShieldCheck } from 'lucide-react';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import type { T } from './pluginModel';
/**
* How plugins are contained and where that ends, opened from the shield in the panel's
* header. It used to sit under the list as a card of its own, which pushed a wall of
* text into the page every admin scrolls past.
*/
export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) {
const sections: Array<[string, string]> = [
[t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')],
[t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')],
[t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')],
[t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')],
[t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')],
[t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')],
[t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')],
];
return (
<DialogShell
onClose={onClose}
labelledBy="plugin-security-title"
width="detail"
header={
<DialogHeader
tile={
<DialogTile>
<ShieldCheck size={20} className="text-content-secondary" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId="plugin-security-title"
title={t('admin.plugins.security.short')}
sub={t('admin.plugins.reviewedMeaning')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.ok')}
</DialogButton>
</DialogFooter>
}
>
<div className="grid gap-2.5 sm:grid-cols-2">
{sections.map(([h, b]) => (
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
{h}
</h4>
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>
{b}
</p>
</div>
))}
</div>
</DialogShell>
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState, type CSSProperties } from 'react';
/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */
const ROW_MENU_MAX_H = 260;
/**
* Where a portaled menu sits: right-aligned under its trigger, or above it when the
* window has no room below. Fixed, because the cards around the toolbar and the rows
* clip whatever overflows them.
*/
export function anchorMenu(trigger: HTMLElement | null): CSSProperties {
const r = trigger?.getBoundingClientRect();
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
const spaceBelow = window.innerHeight - r.bottom;
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
return {
position: 'fixed',
right: Math.max(8, window.innerWidth - r.right),
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
zIndex: 100,
};
}
/** One entry of a popover menu, in the packing popovers' shape. */
export const MENU_ITEM =
'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary';
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
export function useFollowTrigger(open: boolean) {
const [, reanchor] = useState(0);
useEffect(() => {
if (!open) return;
const onMove = () => reanchor((n) => n + 1);
window.addEventListener('scroll', onMove, true);
window.addEventListener('resize', onMove);
return () => {
window.removeEventListener('scroll', onMove, true);
window.removeEventListener('resize', onMove);
};
}, [open]);
}
@@ -111,8 +111,14 @@ describe('pluginModel', () => {
blocked: false,
version: '0.9.0',
});
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' });
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' });
// Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
expect(installOffer(incompatible, t)).toMatchObject({
blocked: false,
label: 'admin.plugins.install',
warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' },
});
// TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice.
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } });
});
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
@@ -298,7 +298,8 @@ export function installOffer(
title,
};
}
return { blocked: true, label: t('admin.plugins.incompatible'), title };
// No version fits this TREK: the admin may still install this one plugin at their own risk.
return bypassOffer(item, t, title, true);
}
/**
@@ -1,4 +1,4 @@
// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells.
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { DragEvent } from 'react';
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
expect(result.current.confirmUninstall).toBeNull();
});
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
serve([
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
]);
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
const { result } = await mounted();
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
expect(result.current.bypass.warning).toMatchObject({
acceptedByAdmin: true,
action: 'activate',
trekRange: '>=3.2.0 <4.0.0',
});
await act(async () => {
result.current.bypass.confirm();
});
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
});
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
serve([], [entry({ id: 'beta', name: 'Beta' })]);
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
const { result } = await mounted();
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
act(() => result.current.install('beta', undefined, warn));
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
await act(async () => {
result.current.bypass.confirm();
});
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
});
});
@@ -317,9 +317,14 @@ export function usePluginsAdmin() {
act(
id,
() =>
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
}),
adminApi
.pluginInstall(id, {
...(version ? { version } : {}),
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
})
.then((r) => {
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
}),
t('admin.plugins.installed')
)
);
@@ -392,10 +397,10 @@ export function usePluginsAdmin() {
}
};
const attemptActivate = (p: PluginRow) => {
const attemptActivate = (p: PluginRow, acceptTrekRange = false) => {
const cascaded = autoEnabledDeps(p);
return adminApi
.pluginActivate(p.id)
.pluginActivate(p.id, false, acceptTrekRange)
.then(() => {
toast.success(t('admin.plugins.activated'));
if (cascaded.length)
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
return;
}
setBusy(p.id);
setMenu(null);
attemptActivate(p).finally(() => {
setBusy(null);
refresh();
});
const run = (acceptTrekRange: boolean) => {
setBusy(p.id);
setMenu(null);
attemptActivate(p, acceptTrekRange).finally(() => {
setBusy(null);
refresh();
});
};
// Outside the TREK versions its author declared: the admin may run it anyway, after
// reading what that risks. The choice holds for this version of the plugin only.
if (p.dependencyStatus === 'hostIncompatible') {
const warn: RangeWarning = {
name: p.name,
trekRange: p.trekRange ?? null,
hostVersion: p.hostVersion ?? '?',
acceptedByAdmin: true,
onConfirm: null,
action: 'activate',
};
bypass.guard(warn, () => run(true));
return;
}
run(false);
};
// Download a missing/outdated plugin dependency (latest compatible for its range,
@@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => {
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
// An admin's own "run it anyway" for this one plugin says so, not "version checks off".
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
label: 'admin.plugins.dep.trekAcceptedUnknown',
})
})
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
it('bypassOffer: an enabled Install that carries what the dialog must say first', () => {
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
expect(offer).toEqual({
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
blocked: false, label: 'admin.plugins.install', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null },
})
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true })
})
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.title',
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
})
it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => {
const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} }
expect(rangeWarningCopy(mine, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin',
body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({
body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}',
confirmLabel: 'admin.plugins.activateAnyway',
})
})
})
+61 -22
View File
@@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n'
type T = (k: string, p?: Record<string, unknown>) => string
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
/**
* The server's "this only went through because the version check was set aside" marker:
* by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
* TREK_PLUGINS_IGNORE_TREK_RANGE.
*/
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean }
/**
* What the range-bypass warning needs to say, and what to do if the admin accepts (null = a
* notice). `action` names what the confirm button does: install (the default) or activate.
*/
export interface RangeWarning extends TrekRangeBypass {
name: string
onConfirm: (() => void) | null
action?: 'install' | 'activate'
}
/**
* The chip a row carries while it runs outside its declared range on the operator's
@@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm:
*/
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
if (!b) return null
const label = b.acceptedByAdmin
? b.trekRange
? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekAcceptedUnknown')
: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown')
return {
icon: AlertTriangle,
label: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown'),
label,
blocked: false,
warn: true,
}
}
/**
* The Install button for a registry entry the server flagged incompatible, once the
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
* regardless, so the button says exactly that — and carries what the warning has to say
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
* The Install button for a registry entry the server flagged incompatible: the server will
* take the newest version regardless, so the button says exactly that — and carries what
* the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
* plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin.
* `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
*/
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
return {
blocked: false,
label: t('admin.plugins.installAnyway'),
title,
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
export function bypassOffer(
item: { name: string; trek?: string | null; hostVersion?: string },
t: T,
title: string,
byAdmin = false
) {
const warn: RangeWarning = {
name: item.name,
trekRange: item.trek ?? null,
hostVersion: item.hostVersion ?? '?',
acceptedByAdmin: byAdmin,
onConfirm: null,
}
return { blocked: false, label: t('admin.plugins.install'), title, warn }
}
/**
@@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers
* whole point: the admin is accepting that the author never vouched for this TREK and
* that, in rare cases, that can cost them data.
*/
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
export function rangeWarningCopy(
w: RangeWarning,
t: T
): { title: string; body: string; confirm: boolean; confirmLabel: string } {
const confirm = w.onConfirm !== null
const vars = { name: w.name, range: w.trekRange, host: w.hostVersion }
const body = w.acceptedByAdmin
? w.trekRange
? t('admin.plugins.rangeBypass.bodyAdmin', vars)
: t('admin.plugins.rangeBypass.bodyAdminUnknown', vars)
: w.trekRange
? t('admin.plugins.rangeBypass.body', vars)
: t('admin.plugins.rangeBypass.bodyUnknown', vars)
const title = w.acceptedByAdmin
? t('admin.plugins.rangeBypass.titleAdmin')
: confirm
? t('admin.plugins.rangeBypass.title')
: t('admin.plugins.rangeBypass.noticeTitle')
return {
confirm,
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
body: w.trekRange
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
title,
body,
confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
}
}
@@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes';
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantStatus } from './useAssistantStatus';
import { useAssistantThread } from './useAssistantThread';
/**
@@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
const minimized = useAssistantStore((s) => s.window.minimized);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
const home = useAssistantHome(setPicked);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useAssistantStatus();
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
@@ -0,0 +1,77 @@
/**
* FE-AI-STATUS-001 to FE-AI-STATUS-003
*
* The views of the assistant ask for its status through this hook: not while
* both addons that open /api/ai are off, where the server answers 404, and
* as soon as one comes on, which the addon list can say after the view
* mounted (the first load, an admin's switch). The assistant's own addon
* switching asks again while AI parsing keeps /api/ai open.
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
import { useAddonStore } from '../../store/addonStore';
import { useAssistantStore } from '../../store/assistantStore';
import { useAssistantStatus } from './useAssistantStatus';
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
/** The addon list as it lands from the server, with these addons on. */
const addonsOn = (...ids: string[]) =>
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.restoreAllMocks();
});
describe('useAssistantStatus', () => {
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
renderHook(() => useAssistantStatus());
act(() => addonsOn('mcp'));
expect(status).not.toHaveBeenCalled();
expect(useAssistantStore.getState().status).toBeNull();
act(() => addonsOn('ai_assistant'));
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
expect(status).toHaveBeenCalledTimes(1);
// The list read again with the assistant still on asks nothing more.
act(() => addonsOn('ai_assistant', 'mcp'));
expect(status).toHaveBeenCalledTimes(1);
});
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
addonsOn('llm_parsing');
renderHook(() => useAssistantStatus());
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
expect(status).toHaveBeenCalledTimes(1);
act(() => addonsOn());
expect(useAssistantStore.getState().status).toBeNull();
expect(status).toHaveBeenCalledTimes(1);
});
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
addonsOn('llm_parsing');
renderHook(() => useAssistantStatus());
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
act(() => addonsOn('llm_parsing', 'ai_assistant'));
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
act(() => addonsOn('llm_parsing'));
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
});
});
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAddonStore } from '../../store/addonStore';
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
/**
* Asks for the assistant's status when a view of it mounts, and again when
* either addon that opens /api/ai is switched: the addon list can land after
* the view, and an admin can switch either addon while it is open. The
* assistant's own addon counts on its own, since the status says it runs only
* while that one is on, even with AI parsing keeping /api/ai open. The store
* asks the server only while one is on (aiRoutesOpen).
*/
export function useAssistantStatus(): void {
const loadStatus = useAssistantStore((s) => s.loadStatus);
const routesOpen = useAddonStore(aiRoutesOpen);
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
useEffect(() => {
void loadStatus();
}, [routesOpen, assistantOn, loadStatus]);
}
@@ -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>
+14 -2
View File
@@ -25,13 +25,25 @@ interface CollabLink {
pinned: boolean | number
}
// The glyph holds the place until a favicon has really arrived: a host that never
// answers, or answers with a blank pixel, left the chip with an empty square.
function LinkIcon({ url }: { url: string }) {
const [failed, setFailed] = useState(false)
const [state, setState] = useState<'loading' | 'loaded' | 'failed'>('loading')
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
const loaded = state === 'loaded'
return (
<span className="collab-link-chip__icon" aria-hidden="true">
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
{!loaded && <Link2 size={13} />}
{favicon && state !== 'failed' && (
<img
src={favicon}
alt=""
hidden={!loaded}
onLoad={(e) => setState(e.currentTarget.naturalWidth > 2 ? 'loaded' : 'failed')}
onError={() => setState('failed')}
/>
)}
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
</span>
)
@@ -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', () => {
@@ -38,6 +38,20 @@ function VoterChip({ name, src, offset }: { name: string; src: string | null; of
);
}
/** The voters past the third, as a count on the end of the stack; their names on hover. */
function MoreVoters({ names }: { names: string[] }) {
return (
<Tooltip label={names.map((name) => name || '?').join(', ')}>
<span
className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full border-[1.5px] border-surface-card bg-surface-tertiary px-[3px] font-bold tabular-nums text-content-muted"
style={{ ...fs(7), marginInlineStart: -5 }}
>
+{names.length}
</span>
</Tooltip>
);
}
/** A fact about the poll on its head band, set like the type chip on a booking card. */
export function PollChip({
icon,
@@ -138,6 +152,7 @@ export function PollOptionRow({
{voters.slice(0, 3).map((voter, index) => (
<VoterChip key={voter.key} name={voter.name} src={voter.src} offset={index > 0} />
))}
{voters.length > 3 && <MoreVoters names={voters.slice(3).map((voter) => voter.name)} />}
</span>
)}
{pending && <span className="sr-only">{pending.label}</span>}
@@ -191,6 +191,18 @@ describe('CollabPolls', () => {
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
});
it('FE-COMP-POLLS-025: past three voters the stack ends in a count of the rest', async () => {
const voters = ['anna', 'ben', 'cleo', 'dana', 'emil'].map((username, i) => ({ user_id: 10 + i, username, avatar_url: null }));
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ is_closed: true, options: [{ id: 1, text: 'Paris', label: 'Paris', voters }, { id: 2, text: 'Rome', label: 'Rome', voters: [] }] })] }),
),
);
render(<CollabPolls {...defaultProps} />);
expect(await screen.findByText('+2')).toBeInTheDocument();
expect(screen.queryByText('D')).not.toBeInTheDocument();
});
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
@@ -1,4 +1,7 @@
import { render, screen } from '../../../tests/helpers/render'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { buildTrip } from '../../../tests/helpers/factories'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -171,6 +174,20 @@ describe('WhatsNextWidget', () => {
expect(screen.getByText('TBD')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-018: a hotel without a time of its own shows the check-in of the stay it begins', async () => {
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({
accommodations: [{ id: 7, trip_id: 1, place_id: 40, start_day_id: 1, end_day_id: 2, check_in: '15:00', check_out: '11:00' }],
})))
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
assignments: { '1': [makeAssignment(40, { name: 'Hotel Kea', place_time: null })] },
})
render(<WhatsNextWidget />)
expect(await screen.findByText('15:00')).toBeInTheDocument()
expect(screen.queryByText('TBD')).not.toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -8,6 +8,8 @@ import { fs } from '../shared/DialogShell'
import CollabPanelHead from './CollabPanelHead'
import EmptyState from '../shared/EmptyState'
import { localToday } from '../Planner/today'
import { useTripStays } from '../Planner/useTripStays'
import { stayStartingOn } from '../../utils/dayMerge'
function formatTime(timeStr, is12h) {
if (!timeStr) return ''
@@ -48,6 +50,10 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
const { days, assignments } = useTripStore()
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
// A hotel on the day its stay begins is timed by the check-in when the stop has no time of its own.
const tripId = useTripStore(s => s.trip?.id ?? 0)
const { tripAccommodations, loadAccommodations } = useTripStays(tripId)
useEffect(() => { loadAccommodations() }, [loadAccommodations])
const upcoming = useMemo(() => {
const now = new Date()
@@ -60,14 +66,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
const dayAssignments = assignments[String(day.id)] || []
for (const a of dayAssignments) {
if (!a.place) continue
const time = a.place.place_time || stayStartingOn(tripAccommodations, a.place.id, day.id)?.check_in || null
// Include: today (not started yet, or still running) + all future days
const isFutureDay = day.date > nowDate
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
const isTodayFuture = day.date === nowDate && (!time || time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
if (isFutureDay || isTodayFuture) {
items.push({
id: a.id,
name: a.place.name,
time: a.place.place_time,
time,
endTime: a.place.end_time,
date: day.date,
dayTitle: day.title,
@@ -88,7 +95,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
})
return items.slice(0, 8)
}, [days, assignments, tripMembers])
}, [days, assignments, tripMembers, tripAccommodations])
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -0,0 +1,260 @@
/**
* The collection map frames every place of the list in the box it is given (FE-COMP-COLMAP-*).
*
* Real Leaflet on purpose, as in MapView.leaflet.test.tsx. The places went missing between
* two sizes: the renderer opened on a camera framed for the browser window, while this map
* is a box a fraction of that, and a mocked map has neither. The window here is a desktop
* of 1600 x 1000 and the map the 567 x 724 split it gets beside the list, where the demo's
* cafes opened on northern France with one pin in sight.
*/
import type { CollectionPlace } from '@trek/shared';
import L from 'leaflet';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import CollectionMap from './CollectionMap';
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({
position: null,
mode: 'off',
error: null,
errorCode: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
}),
}));
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({ FerrataMapLayerLeaflet: () => null }));
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
vi.mock('../../hooks/useTileUrl', () => ({ useTileUrl: () => 'https://tiles.example.test/{z}/{x}/{y}.png' }));
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}));
// Every map this file builds, newest last.
const maps: L.Map[] = [];
L.Map.addInitHook(function (this: L.Map) {
maps.push(this);
});
/** Half a selected pin (44 px): a point this far inside an edge keeps its whole pin on the map. */
const PIN = 22;
/** The desktop panel's control bar ends 52 px below the map's top edge. */
const BAR = 52;
/** The satellite and locate buttons fill the bottom 54 px of the map's corners. */
const CONTROLS = 54;
// Leaflet sizes the map from its container, which jsdom lays out at 0 x 0.
const box = { width: 567, height: 724 };
const sized = ['clientWidth', 'clientHeight'] as const;
const originals = new Map<string, PropertyDescriptor | undefined>();
const startWindow = { width: window.innerWidth, height: window.innerHeight };
function setWindow(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width });
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height });
}
beforeAll(() => {
for (const key of sized) {
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
Object.defineProperty(HTMLElement.prototype, key, {
configurable: true,
get: () => (key === 'clientWidth' ? box.width : box.height),
});
}
});
afterAll(() => {
for (const key of sized) {
const original = originals.get(key);
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
}
});
beforeEach(() => {
// The desktop split beside the list, unless a test says otherwise.
setWindow(1600, 1000);
Object.assign(box, { width: 567, height: 724 });
});
afterEach(() => {
setWindow(startWindow.width, startWindow.height);
resetAllStores();
});
function place(id: number, name: string, lat: number, lng: number): CollectionPlace {
return { id, collection_id: 1, name, lat, lng, status: 'idea' };
}
// The demo's cafes: Vienna twice, Trieste, Rome, Paris, Lisbon, Porto and Copenhagen.
const CAFES = [
place(1, 'Cafe Central', 48.2105, 16.366),
place(2, 'Cafe Sperl', 48.1999, 16.3634),
place(3, 'Caffe San Marco', 45.6513, 13.777),
place(4, "Sant'Eustachio il Caffe", 41.8984, 12.4753),
place(5, 'Cafe de Flore', 48.8542, 2.3331),
place(6, 'A Brasileira', 38.7102, -9.1417),
place(7, 'Cafe Majestic', 41.1472, -8.6064),
place(8, 'The Coffee Collective', 55.696, 12.5509),
];
// The demo's viewpoints, from the Norwegian fjords to the Cinque Terre.
const VIEWPOINTS = [
place(11, 'Seceda', 46.6046, 11.722),
place(12, 'Preikestolen', 58.9863, 6.1904),
place(13, 'Trolltunga', 60.1241, 6.74),
place(14, 'Rigi Kulm', 47.056, 8.485),
place(15, 'Zugspitze', 47.4211, 10.9852),
place(16, 'Eibsee loop', 47.4575, 10.9918),
place(17, 'Hallstatt overlook', 47.5622, 13.6493),
place(18, 'Monterosso al Mare', 44.1456, 9.654),
];
// Temples and shrines across Japan: eight as well, and far outside the cafes' frame.
const TEMPLES = [
place(31, 'Fushimi Inari', 34.9671, 135.7727),
place(32, 'Kiyomizu-dera', 34.9949, 135.785),
place(33, 'Senso-ji', 35.7148, 139.7967),
place(34, 'Chureito Pagoda', 35.5014, 138.8016),
place(35, 'Itsukushima', 34.2959, 132.3199),
place(36, 'Himeji Castle', 34.8394, 134.6939),
place(37, 'Shirakawa-go', 36.2578, 136.9066),
place(38, 'Todai-ji', 34.689, 135.8398),
];
// "All saved" across the globe, from Patagonia to Taiwan.
const WORLD = [
place(21, 'Torres del Paine', -50.942, -73.4068),
place(22, 'Pico do Arieiro', 32.7356, -16.9281),
place(23, 'Landmannalaugar', 63.9926, -19.0607),
place(24, 'Taroko Gorge', 24.1594, 121.6211),
place(25, 'Lycian Way', 36.5497, 29.1153),
...VIEWPOINTS.slice(0, 3),
];
function mapOf(places: CollectionPlace[], underBar: boolean) {
return (
<CollectionMap places={places} selectedPlaceId={null} onOpenPlace={vi.fn()} dark={false} underBar={underBar} />
);
}
async function renderMap(places: CollectionPlace[], underBar = true) {
const view = render(mapOf(places, underBar));
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).not.toBeNull());
return { view, map: maps[maps.length - 1] };
}
/** Where each place sits on the map, in pixels from its top left corner. */
function pointsOf(map: L.Map, places: CollectionPlace[]): L.Point[] {
return places.map((p) => map.latLngToContainerPoint([p.lat ?? 0, p.lng ?? 0]));
}
/** Every pin whole on the map, below the bar when there is one, and above the bottom controls. */
function expectEveryPinClear(map: L.Map, places: CollectionPlace[], top: number) {
for (const { x, y } of pointsOf(map, places)) {
expect(x).toBeGreaterThanOrEqual(PIN);
expect(x).toBeLessThanOrEqual(box.width - PIN);
expect(y).toBeGreaterThanOrEqual(top + PIN);
expect(y).toBeLessThanOrEqual(box.height - CONTROLS - PIN);
}
}
/** Whether an element around the map carries the class. */
function wrappedIn(map: L.Map, className: string): boolean {
for (let el = map.getContainer().parentElement; el; el = el.parentElement) {
if (el.classList.contains(className)) return true;
}
return false;
}
/** The places fill a good part of the free box along one axis: framed, not shown from orbit. */
function expectFramedClosely(map: L.Map, places: CollectionPlace[], top: number) {
const points = pointsOf(map, places);
const spanX = Math.max(...points.map((p) => p.x)) - Math.min(...points.map((p) => p.x));
const spanY = Math.max(...points.map((p) => p.y)) - Math.min(...points.map((p) => p.y));
const freeX = box.width - 2 * PIN;
const freeY = box.height - top - CONTROLS - 2 * PIN;
expect(Math.max(spanX / freeX, spanY / freeY)).toBeGreaterThan(0.4);
}
describe('CollectionMap framing', () => {
it('FE-COMP-COLMAP-001: the desktop split opens with every cafe on screen, clear of the edges and the bar', async () => {
const { map } = await renderMap(CAFES);
expectEveryPinClear(map, CAFES, BAR);
expectFramedClosely(map, CAFES, BAR);
});
it('FE-COMP-COLMAP-002: another list of the same length frames again', async () => {
const { view, map } = await renderMap(CAFES);
// Both lists hold eight places, so a fit keyed on the count never noticed the switch.
// The temples lie far east of the cafes' frame, so their pins come clear only by a new fit.
expect(pointsOf(map, TEMPLES).every((p) => p.x > box.width)).toBe(true);
view.rerender(mapOf(TEMPLES, true));
await waitFor(() => expectEveryPinClear(map, TEMPLES, BAR));
expectFramedClosely(map, TEMPLES, BAR);
});
it('FE-COMP-COLMAP-003: a map the user moved stays put through a reload, a new order or an edited place', async () => {
const { view, map } = await renderMap(CAFES);
act(() => {
map.setView([50, 10], 7, { animate: false });
});
const reloaded = [...CAFES].reverse().map((p) => ({ ...p, status: 'visited' as const }));
view.rerender(mapOf(reloaded, true));
expect(map.getZoom()).toBe(7);
expect(map.getCenter().lat).toBeCloseTo(50, 5);
expect(map.getCenter().lng).toBeCloseTo(10, 5);
});
it('FE-COMP-COLMAP-004: a filter that hides every place leaves the map where it was', async () => {
const { view, map } = await renderMap(CAFES);
const zoom = map.getZoom();
const center = map.getCenter();
view.rerender(mapOf([], true));
expect(map.getZoom()).toBe(zoom);
expect(map.getCenter().lat).toBeCloseTo(center.lat, 5);
expect(map.getCenter().lng).toBeCloseTo(center.lng, 5);
});
it('FE-COMP-COLMAP-005: a list of one opens on its town, not on its street', async () => {
const { map } = await renderMap([CAFES[0]]);
expectEveryPinClear(map, [CAFES[0]], BAR);
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getZoom()).toBeLessThanOrEqual(13);
});
it('FE-COMP-COLMAP-006: "All saved" across the globe keeps Patagonia and Taiwan on screen', async () => {
const { map } = await renderMap(WORLD);
expectEveryPinClear(map, WORLD, BAR);
});
it("FE-COMP-COLMAP-007: the phone's card frames every cafe clear of its edges and its bottom controls", async () => {
setWindow(390, 844);
Object.assign(box, { width: 356, height: 414 });
const { map } = await renderMap(CAFES, false);
expectEveryPinClear(map, CAFES, 0);
expectFramedClosely(map, CAFES, 0);
// The locate and satellite buttons stand on --bottom-nav-h, which lifts them over the tab
// bar under a full-screen map. The card ends above the tab bar, so its wrapper zeroes it
// and the buttons sit in the bottom 54 px the frame keeps free.
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
});
});
@@ -1,9 +1,10 @@
import React from 'react'
import React, { useMemo } from 'react'
import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
import type { ViewportPadding } from '../../utils/mapViewport'
interface CollectionMapProps {
places: CollectionPlace[]
@@ -12,20 +13,75 @@ interface CollectionMapProps {
/** Clicking the map background clears the selection. */
onDeselect?: () => void
dark: boolean
/** A control bar floats over the top of the map (the desktop panel's), so the frame starts below it. */
underBar?: boolean
}
/** How far a pin reaches from its point: half a selected pin (44 px) and the rating badge on its corner. */
const PIN_REACH = 26
/** What every edge keeps clear: a pin's reach and a little air, so no pin touches the frame. */
const EDGE = PIN_REACH + 14
/** The desktop panel's control bar: 12 px off the top edge and at most 40 px tall. */
const TOP_BAR = 52
/** The round controls in the bottom corners (satellite, locate): 12 px off the edge and 42 px tall. */
const BOTTOM_CONTROLS = 54
/**
* Half the side of the square framed around a lone place, in degrees: about 11 km
* from north to south, so a list of one opens on its town rather than on the street
* the closest fit would zoom to.
*/
const LONE_PLACE_SPAN = 0.05
/** What the frame keeps clear of pins, in map pixels per edge. */
function framePadding(underBar: boolean): ViewportPadding {
const top = (underBar ? TOP_BAR : 0) + EDGE
return { top, right: EDGE, bottom: BOTTOM_CONTROLS + PIN_REACH, left: EDGE } // rtl-lint-disable: pixels of the map, not sides of the text
}
/**
* Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
* The coordinates the map frames. A lone place, or several on one spot, has no extent
* to fit, so a small square around it stands in.
*/
function framePoints(places: CollectionPlace[]): [number, number][] {
const points = places.flatMap(p => (
typeof p.lat === 'number' && typeof p.lng === 'number' ? [[p.lat, p.lng] as [number, number]] : []
))
if (points.length === 0) return points
const [lat, lng] = points[0]
if (points.some(([a, b]) => a !== lat || b !== lng)) return points
return [
[Math.max(-90, lat - LONE_PLACE_SPAN), lng - LONE_PLACE_SPAN],
[Math.min(90, lat + LONE_PLACE_SPAN), lng + LONE_PLACE_SPAN],
]
}
/**
* Map view: reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
* clustering). One of the three list views; clicking a marker selects the place.
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
* just fills it.
*
* The map frames every place itself, through the renderers' focus props. Their
* opening camera is framed for the browser window, and this map is a box a fraction
* of that size (the desktop split, the phone's card), so it opened zoomed in on the
* middle of the list with most places outside; and the count it used to key the fit
* on never changed between two lists of eight. The frame is fitted in the map's own
* box instead, clear of the edges and of the controls floating over it, and lands
* while the map is built rather than flying in.
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
const pts = mappablePlaces(places)
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
const pts = useMemo(() => mappablePlaces(places), [places])
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
const focusPoints = useMemo(() => framePoints(pts), [pts])
// The places as a set: another list, a filter or a new place frames again, while a
// reload, another sort order or an edited note leaves a map the user moved alone.
const focusKey = useMemo(() => pts.map(p => p.id).sort((a, b) => a - b).join(','), [pts])
return (
<div style={{ width: '100%', height: '100%' }}>
// The map's own controls stand on --bottom-nav-h to clear the phone's tab bar on a
// full-screen map. This one ends above the tab bar, so they belong on its bottom edge,
// where the frame's bottom padding keeps the pins out from under them.
<div className="[--bottom-nav-h:0px]" style={{ width: '100%', height: '100%' }}>
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
@@ -35,10 +91,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
// question a small picture cannot answer.
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
// No center/zoom: the map frames itself on the collection's places at mount, and
// falls back to the world view for a collection with none.
tileUrl={tileUrl}
fitKey={pts.length}
focusPoints={focusPoints}
focusKey={focusKey}
fitPadding={framePadding(underBar)}
/>
</div>
)
@@ -0,0 +1,51 @@
/**
* The collections page's map panel tells the map whether its bar floats over it (FE-COMP-COLMAPPANEL-*).
*
* The frame itself is CollectionMap.test.tsx's. The page tests stand the panel in with a double,
* so this is where the bar's height reaches the frame: without it the top pins land under the bar.
*/
import { describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import CollectionMapPanel from './CollectionMapPanel';
const handedToMap = vi.hoisted(() => ({ underBar: undefined as boolean | undefined }));
vi.mock('./CollectionMap', () => ({
default: (props: { underBar?: boolean }) => {
handedToMap.underBar = props.underBar;
return null;
},
}));
function panel(overlay: boolean) {
return (
<CollectionMapPanel
places={[]}
selectedPlaceId={null}
onSelect={vi.fn()}
onDeselect={vi.fn()}
dark={false}
overlay={overlay}
view="list"
onToggleView={vi.fn()}
search=""
onSearch={vi.fn()}
t={(key) => key}
/>
);
}
describe('CollectionMapPanel', () => {
it('FE-COMP-COLMAPPANEL-001: the desktop split floats its bar over the map, which frames its places below it', () => {
const { container } = render(panel(true));
expect(container.querySelector('.col-map-topbar')).not.toBeNull();
expect(handedToMap.underBar).toBe(true);
});
it('FE-COMP-COLMAPPANEL-002: a map without the bar frames its places from its own top edge', () => {
const { container } = render(panel(false));
expect(container.querySelector('.col-map-topbar')).toBeNull();
expect(handedToMap.underBar).toBe(false);
});
});
@@ -50,6 +50,7 @@ export default function CollectionMapPanel({
onOpenPlace={onSelect}
onDeselect={onDeselect}
dark={dark}
underBar={overlay}
/>
{overlay && (
<div className="col-map-topbar">
@@ -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'),
};
}
@@ -7,7 +7,7 @@ import CustomSelect from '../../shared/CustomSelect'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import Tooltip from '../../shared/Tooltip'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
@@ -6,7 +6,7 @@ import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
/**
@@ -1,6 +1,6 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import TrekIcon from '../../shared/TrekIcon'
import { Badge } from './DocSyncBits'
import { toggledDirection, type SyncDirection } from './docSyncModel'
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react'
import { AlertCircle, ArrowRight, Check, FolderSync, Loader2, Plus } from 'lucide-react'
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
import { StateBadge } from './DocSyncBits'
import { slugFor } from './docSyncModel'
import { useConflicts } from './useConflicts'
@@ -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>
);
}
@@ -534,6 +534,21 @@ describe('MapView', () => {
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
})
it('FE-COMP-MAPVIEW-089: a frame handed over while the map is built lands at once, a later one flies', () => {
// The map opens on a guess framed for the browser window. A map in a smaller box, the
// collection's beside its list, hands its own frame over at once, and must open there
// rather than show the guess and then fly to the frame.
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
const { rerender } = render(<MapView places={[]} focusKey="list:17" focusPoints={cafes} />)
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: false, maxZoom: 16 })]])
mapMock.fitBounds.mockClear()
// Another list on the same map is a change the user asked for, and it flies as before.
const viewpoints: [number, number][] = [[60.12, 6.74], [44.15, 9.65], [47.56, 13.65]]
rerender(<MapView places={[]} focusKey="list:20" focusPoints={viewpoints} />)
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: true, maxZoom: 16 })]])
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
+9 -23
View File
@@ -427,35 +427,16 @@ function MapController({ center, zoom }: MapControllerProps) {
// fit to that day's destinations immediately, then — once the day's route has
// finished computing asynchronously — re-fit once more to include the full route
// polyline, so a route that bulges past its stops stays in view (#1128).
interface BoundsControllerProps {
// The focus props are the contract's, documented there once rather than copied here.
interface BoundsControllerProps extends Pick<MapViewProps, 'focusPoints' | 'focusKey' | 'fitPadding'> {
places: Place[]
routeCoords: [number, number][]
fitKey: number
paddingOpts: L.FitBoundsOptions
/** The map was built already framed on these places, so the opening fit has nothing to do. */
framedOnMount?: boolean
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number | string
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
* `paddingOpts` knows the planner's panels and nothing else, and on a phone it is a flat
* margin. A shell that lays its own bars over the map passes what they cover, so the
* frame lands in the part still visible. Only the fit on `focusPoints` reads it.
* Compared by value: the same numbers in a new object do not refit, while new numbers
* refit the points already handed over, because the chrome they must clear has moved.
*/
fitPadding?: ViewportPadding
}
/** A padding box as Leaflet's fit options spell it, corner pairs rather than edges. */
@@ -473,6 +454,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
const fitRan = useRef(false)
const didInitialFocus = useRef(false)
const prevFocusKey = useRef(focusKey)
const built = useRef(false)
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
@@ -493,7 +475,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
* (#1982). MapViewGL never had the nudge, so this also brings the two
* renderers back into agreement.
*/
map.fitBounds(bounds, { ...padding, maxZoom: 16, animate: true })
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
}
} catch {}
}, [map, paddingOpts])
@@ -534,6 +516,10 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
// The first run is the map being built. A frame handed over by then is where the map
// opens, so it lands at once instead of flying in from the window-sized opening guess.
const opening = !built.current
built.current = true
if (!focusPoints?.length) return
if (focusKey !== undefined) {
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current
@@ -543,7 +529,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
}
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
fitTo(focusPoints, { ...(fitPadding ? leafletPadding(fitPadding) : paddingOpts), animate: !opening })
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
return null
+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';
@@ -7,8 +7,8 @@ import { resetAllStores } from '../../../tests/helpers/store';
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
import { useSettingsStore } from '../../store/settingsStore';
// The planner-waypoint and location-search camera cases of MapViewGL, and the tour
// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
// The planner-waypoint, location-search and antimeridian camera cases of MapViewGL, and
// the tour map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
// Stable fake map so fitBounds call counts survive re-renders. The canvas
// container is a single element so listeners registered by the component are
@@ -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', () => ({}));
@@ -357,6 +356,31 @@ describe('MapViewGL', () => {
expect(glMap.fitBounds).toHaveBeenCalledOnce();
});
it('frames handed-over points across the antimeridian the short way, as the opening camera does', async () => {
loadOnAttach();
// Fiji, Tonga and Samoa, a collection's list: a box from plain min and max would run
// from -175 to 178, round the far side of the world, with each pin on an edge.
render(
<MapViewGL
places={[]}
followSelection={false}
focusKey="1,2,3"
focusPoints={[
[-18.14, 178.44],
[-21.14, -175.2],
[-13.83, -171.76],
]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
const framed = glBounds.instances.find((bounds) => bounds === glMap.fitBounds.mock.calls[0][0]);
const lngs = framed?.extend.mock.calls.map(([lngLat]) => Math.round(lngLat[0] * 100) / 100);
expect(lngs).toEqual([178.44, 184.8, 188.24]);
});
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
async (provider) => {
+19 -20
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,
@@ -945,7 +944,7 @@ describe('MapViewGL', () => {
/>,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledOnce()
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 0 })]])
glMap.fitBounds.mockClear()
const mapConstructions = (maplibregl.Map as any).mock.calls.length
const retainedCenter = { lng: 135.8, lat: 35.2 }
@@ -980,7 +979,7 @@ describe('MapViewGL', () => {
/>,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledOnce()
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 400 })]])
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
+8 -8
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'
@@ -47,7 +48,7 @@ import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack,
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
@@ -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,
@@ -2091,7 +2091,7 @@ export function MapViewGL({
didInitialFocusRef.current = true
}
const bounds = new gl.LngLatBounds()
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
alongShortestArc(focusPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
// A day fit still waiting on its route must not overwrite this a moment later.
pendingRouteFitRef.current = null
try {
@@ -2099,7 +2099,7 @@ export function MapViewGL({
padding: fitPadding ?? paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
duration: mapReady ? 400 : 0, // handed over while the map is built: it opens there, no flight in
})
} catch { /* noop */ }
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -426,6 +426,23 @@ describe('calculateRouteWithLegs', () => {
expect(second).toBe(first)
})
it('FE-COMP-ROUTECALCULATOR-035b: a route answered once is still there after a reload, without asking the router (#2794)', async () => {
let hits = 0
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => { hits++; return HttpResponse.json(buildLegsResponse()) }))
const wps = freshWaypoints()
const first = await calculateRouteWithLegs(wps)
const { offlineDb } = await import('../../db/offlineDb')
await vi.waitFor(async () => expect(await offlineDb.routeCache.count()).toBeGreaterThan(0))
// A reload: the module, and with it the cache in memory, starts over.
vi.resetModules()
const reloaded = await import('./RouteCalculator')
const again = await reloaded.calculateRouteWithLegs(wps)
expect(hits).toBe(1)
expect(again).toEqual(first)
})
it('FE-COMP-ROUTECALCULATOR-036: switching the distance unit re-fetches instead of reusing stale text (#1300)', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildLegsResponse())))
const wps = freshWaypoints()
+7 -20
View File
@@ -4,6 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { cacheRoute, rememberRoute, routeInMemory, storedRoute } from './routeStore'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
@@ -196,10 +197,6 @@ function retryAfterMs(response: Response): number | null {
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
}
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
const routeCache = new Map<string, RouteWithLegs>()
const ROUTE_CACHE_MAX = 200
/**
* A route profile is either one of the built-in OSRM profiles or a plugin profile
@@ -540,7 +537,10 @@ export async function calculateRouteWithLegs(
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
const cacheKey = `${plainKey}${avoidScope}`
const cached = routeCache.get(cacheKey)
// A plugin route stays in memory: its answer depends on the trip and the day,
// and may carry live data such as charging stops. The rest is kept on the device
// too, so a trip that shows its whole route opens drawn (#2794).
const cached = pluginScope ? routeInMemory(cacheKey) : await storedRoute(cacheKey)
if (cached) return cached
if (avoiding.length) {
@@ -552,7 +552,7 @@ export async function calculateRouteWithLegs(
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
// than blank, and the answer says that nothing was avoided, with the reason, so the
// rail flags the day instead of implying the setting held on a road nobody weighed.
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
const plain = (await storedRoute(plainKey)) ?? await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(plainKey, plain)
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
}
@@ -591,11 +591,7 @@ export async function calculateRouteWithLegs(
legs,
...(route.viaPoints.length ? { vias: route.viaPoints } : {}),
}
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
rememberRoute(cacheKey, result)
return result
}
@@ -638,15 +634,6 @@ async function osrmWithLegs(
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
}
/** Store a route and drop the oldest once the cache is over its cap. */
function cacheRoute(key: string, route: RouteWithLegs): void {
routeCache.set(key, route)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
}
/**
* A whole chain from the second engine, weighted away from some road classes.
*
+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;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { routeCacheRepo } from '../../repo/routeCacheRepo';
import type { RouteWithLegs } from '../../types';
/**
* Route answers by RouteCalculator's cache key (profile, unit, waypoints,
* avoided roads). Routes are stable, so this keeps the router from being asked
* again on every day switch or reorder: in memory for this visit, and on the
* device for the next one, so a trip that shows its whole route opens drawn
* instead of routing every leg again, one a second (#2794).
*/
const inMemory = new Map<string, RouteWithLegs>();
const MEMORY_MAX = 200;
/** Keeps a route for this visit only, dropping the oldest past the limit. */
export function rememberRoute(key: string, route: RouteWithLegs): void {
inMemory.set(key, route);
if (inMemory.size > MEMORY_MAX) {
const oldest = inMemory.keys().next().value;
if (oldest !== undefined) inMemory.delete(oldest);
}
}
/** The route this visit already holds, without asking the device. */
export function routeInMemory(key: string): RouteWithLegs | undefined {
return inMemory.get(key);
}
/** Keeps a route the router just answered, in memory and on the device. */
export function cacheRoute(key: string, route: RouteWithLegs): void {
rememberRoute(key, route);
void routeCacheRepo.put(key, route);
}
/** The route kept in memory, else the one kept on the device, which then stays in memory too. */
export async function storedRoute(key: string): Promise<RouteWithLegs | null> {
const held = inMemory.get(key);
if (held) return held;
const kept = await routeCacheRepo.get<RouteWithLegs>(key);
if (kept) rememberRoute(key, kept);
return kept;
}
+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 => {
@@ -5,19 +5,22 @@ import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { useSuggestionDropdown } from './useSuggestionDropdown'
import { PlaceSuggestion, SuggestionList, SuggestionRow } from './SuggestionDropdown'
import type { MapMatch } from '../../repo/mapMatchRepo'
interface Props {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
/** A picked suggestion's spot on the map, for a form that saves where the address is (an imported stay). */
onPick?: (match: MapMatch) => void
}
// Free-text address input with location autocomplete, backed by the same maps
// search as LocationSelect. Unlike LocationSelect the typed text is
// authoritative: every keystroke reaches the parent so a hand-written address
// is never lost, and picking a suggestion just replaces the text (#1496).
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
export default function AddressInput({ value, onChange, placeholder, className, onPick }: Props) {
const { locale } = useTranslation()
const placeLang = usePlaceLanguage()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
@@ -60,6 +63,9 @@ export default function AddressInput({ value, onChange, placeholder, className }
// A response still on its way must not repopulate the list behind the pick.
reqIdRef.current++
onChange(r.address || r.name || '')
if (onPick && Number.isFinite(r.lat) && Number.isFinite(r.lng)) {
onPick({ lat: r.lat, lng: r.lng, label: [r.name, r.address].filter(Boolean).join(', ') })
}
setOpen(false)
setResults([])
setLoading(false)
@@ -118,9 +118,10 @@ export function DayPlanPlaceSubline({
const description = place.description || place.address || cat?.name;
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
return (
// One line like the row's name: when the hover controls come out, the description
// gives way and the pills keep their place instead of wrapping onto a second line.
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
// The description stays on one line and gives way first. The pills wrap rather
// than run out of the row: a tour carries a time, its duration and its distance,
// and in the narrow day column the last of them used to be cut off unseen.
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 overflow-hidden">
{place.place_time && dayRowTour && (
// TIME-02: a tour's end, typed or worked out from its start and its time.
<TimePill quiet>
@@ -201,9 +201,9 @@ describe('DayPlanSidebar', () => {
const view = render(<DayPlanSidebar {...props} />);
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
expect(tourPreview).toHaveTextContent(description);
// One line like the row's name, so hover controls make the description give way instead of wrapping.
// The description keeps to one line; the pills beside it wrap rather than run out of the row.
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
expect(tourPreview.parentElement).toHaveClass('flex-wrap', 'overflow-hidden');
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
@@ -29,6 +29,7 @@ import { useReservationForm } from './useReservationForm'
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
import { useBookingFileAttach } from './useBookingFileAttach'
import StayPinStatus from './StayPinStatus'
const EMPTY_FORM = {
title: '', type: 'other', status: 'pending',
@@ -81,7 +82,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const {
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
isEndBeforeStart, endTimeBeforeStartTime, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
} = useReservationForm(EMPTY_FORM, days, places)
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
@@ -102,17 +103,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
setTravelerIds(travelerIdsOf(reservation))
stayPin.reset()
if (reservation) {
setForm({ ...reservationFieldsFrom(reservation, accommodations, places, true), assignment_id: reservation.assignment_id || '' })
} else if (prefill) {
// Review-before-save: populate from a parsed import item, stay in create mode.
const hotelPlaceId = matchPlaceId(prefill._venue?.name || prefill.title)
setForm({
...reservationFieldsFromPrefill(prefill, days, true),
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
place_id: '',
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
hotel_place_id: hotelPlaceId,
})
// A stay the trip does not hold yet: find it on the map now, so the review
// says whether it will show there before it is saved.
if (prefill.type === 'hotel' && !hotelPlaceId) stayPin.lookUp(prefill._venue?.name || prefill.title, prefill._venue?.address)
// Seed the booking's Files with the document this item was parsed from.
setPendingFiles(prefill._sourceFiles ?? [])
} else {
@@ -362,8 +368,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
<EditorField label={t('reservations.locationAddress')}>
<AddressInput value={form.hotel_address} onChange={v => set('hotel_address', v)}
onPick={stayPin.pick}
placeholder={t('reservations.locationPlaceholder')} className={INPUT} />
</EditorField>
<StayPinStatus
pin={stayPin.pin}
hidden={!!form.hotel_place_id}
onRetry={() => stayPin.lookUp(form.title, form.hotel_address)}
/>
</>
)}
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../i18n/TranslationContext';
import StayPinStatus from './StayPinStatus';
import type { StayPin } from './useStayPin';
function show(pin: StayPin, onRetry = vi.fn()) {
render(createElement(TranslationProvider, null, createElement(StayPinStatus, { pin, onRetry })));
return onRetry;
}
describe('StayPinStatus', () => {
it('FE-STAY-PIN-STATUS-001: shows nothing before a lookup', () => {
const { container } = render(
createElement(
TranslationProvider,
null,
createElement(StayPinStatus, { pin: { status: 'idle' }, onRetry: vi.fn() })
)
);
expect(container.textContent).toBe('');
});
it('FE-STAY-PIN-STATUS-001b: a stay already linked to a place of the trip shows nothing', () => {
const { container } = render(
createElement(
TranslationProvider,
null,
createElement(StayPinStatus, { pin: { status: 'missing' }, hidden: true, onRetry: vi.fn() })
)
);
expect(container.textContent).toBe('');
});
it('FE-STAY-PIN-STATUS-002: says it is looking, then where it found the stay', () => {
show({ status: 'searching' });
expect(screen.getByRole('status')).toHaveTextContent('Looking for it on the map');
});
it('FE-STAY-PIN-STATUS-003: a found stay names the match, so a wrong one is plain', () => {
show({ status: 'found', match: { lat: 1, lng: 2, label: 'Hotel Gracery Shinjuku, Tokyo' } });
expect(screen.getByRole('status')).toHaveTextContent('On the map');
expect(screen.getByText('Hotel Gracery Shinjuku, Tokyo')).toBeInTheDocument();
});
it('FE-STAY-PIN-STATUS-004: a missing stay says so and offers to search again', () => {
const onRetry = show({ status: 'missing' });
expect(screen.getByText('Not on the map yet')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /search again/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,62 @@
import { MapPin, MapPinOff, RotateCw } from 'lucide-react';
import { useTranslation } from '../../i18n';
import type { StayPin } from './useStayPin';
interface StayPinStatusProps {
pin: StayPin;
/** A stay already linked to a place of the trip shows on the map through that place. */
hidden?: boolean;
/** Asks the map again with what the form now holds. */
onRetry: () => void;
}
/**
* Whether an imported stay will show on the map, under its address in the
* review: being looked up, found (and where, so a wrong match is plain), or not
* found, with a way to search again before it is saved without a pin. Shared by
* the desktop booking dialog and the phone's booking sheet.
*/
export default function StayPinStatus({ pin, hidden = false, onRetry }: StayPinStatusProps) {
const { t } = useTranslation();
if (hidden || pin.status === 'idle') return null;
if (pin.status === 'searching') {
return (
<div role="status" className="flex items-center gap-2 px-1 text-caption text-content-muted">
<span className="h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-edge border-t-content-muted" />
{t('reservations.import.pinSearching')}
</div>
);
}
if (pin.status === 'found') {
return (
<div role="status" className="flex min-w-0 items-center gap-2 rounded-lg bg-success-soft px-3 py-2 text-caption">
<MapPin size={14} strokeWidth={2.2} className="flex-none text-success" />
<span className="flex-none font-semibold text-success">{t('reservations.import.pinFound')}</span>
<span className="min-w-0 truncate text-content-secondary" title={pin.match.label}>
{pin.match.label}
</span>
</div>
);
}
return (
<div role="status" className="flex items-start gap-3 rounded-xl bg-warning-soft px-3.5 py-3">
<MapPinOff size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" />
<div className="min-w-0 flex-1">
<p className="m-0 text-body font-semibold text-content">{t('reservations.import.pinMissingTitle')}</p>
<p className="m-0 mt-0.5 text-caption text-content-muted">{t('reservations.import.pinMissingBody')}</p>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-edge bg-surface-card px-2.5 py-1 text-caption font-medium text-content hover:bg-surface-hover"
>
<RotateCw size={12} strokeWidth={2.2} />
{t('reservations.import.pinRetry')}
</button>
</div>
</div>
);
}
@@ -287,6 +287,8 @@ export interface ReservationSaveOptions {
isEdit: boolean;
/** The desktop also saves the end of the check-in window. */
withCheckInEnd: boolean;
/** Where the review found an imported stay on the map, so the save creates its place right there. */
venuePin?: { lat: number; lng: number } | null;
}
export type ReservationSaveData = Record<string, unknown> & { title: string };
@@ -344,7 +346,11 @@ export function reservationSaveData(
// import): the save handler geocodes it and creates the place.
venue:
!form.hotel_place_id && (form.hotel_address || form.title)
? { name: form.title, address: form.hotel_address || null }
? {
name: form.title,
address: form.hotel_address || null,
...(opts.venuePin ? { lat: opts.venuePin.lat, lng: opts.venuePin.lng } : {}),
}
: null,
// The typed address, so the save handler can write it through to a linked
// place; an edited address used to be silently dropped (#1496).
@@ -19,6 +19,7 @@ import {
withStartDate,
withStartTime,
} from './reservationFormModel';
import { useStayPin } from './useStayPin';
/**
* The booking form behind the desktop ReservationModal and the phone's booking
@@ -39,6 +40,8 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
const set = (field: keyof F, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }));
const toggleTraveler = (id: number) => setTravelerIds((prev) => toggledTraveler(prev, id));
// Where an imported stay will land on the map, looked up while it is reviewed.
const stayPin = useStayPin();
// Restrict non-hotel booking dates to the trip's span (#1662). Hotels already
// constrain to trip days via their day dropdowns.
@@ -71,6 +74,7 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
pickHotelPlace: (value: string | number) => setForm((prev) => withHotelPlace(prev, value, places)),
pickHotelStart: (value: string | number) => setForm((prev) => withHotelStart(prev, value, days)),
pickHotelEnd: (value: string | number) => setForm((prev) => withHotelEnd(prev, value, days)),
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, opts),
stayPin,
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, { ...opts, venuePin: stayPin.match }),
};
}
@@ -0,0 +1,74 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { findOnMap } from '../../repo/mapMatchRepo';
import { isEffectivelyOffline } from '../../sync/networkMode';
import { useStayPin } from './useStayPin';
vi.mock('../../hooks/usePlaceLanguage', () => ({ usePlaceLanguage: () => 'en' }));
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
vi.mock('../../repo/mapMatchRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../repo/mapMatchRepo')>()),
findOnMap: vi.fn(),
}));
const SPOT = { lat: 35.69, lng: 139.7, label: 'Hotel Gracery Shinjuku' };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
});
describe('useStayPin', () => {
it('FE-STAY-PIN-001: looks the stay up by name and address and says where it found it', async () => {
vi.mocked(findOnMap).mockResolvedValueOnce(SPOT);
const { result } = renderHook(() => useStayPin());
expect(result.current.pin).toEqual({ status: 'idle' });
act(() => result.current.lookUp('Hotel Gracery', '1-19-1 Kabukicho'));
expect(result.current.pin).toEqual({ status: 'searching' });
await waitFor(() => expect(result.current.pin).toEqual({ status: 'found', match: SPOT }));
expect(result.current.match).toEqual(SPOT);
expect(findOnMap).toHaveBeenCalledWith(
['Hotel Gracery 1-19-1 Kabukicho', 'Hotel Gracery', '1-19-1 Kabukicho'],
'en'
);
});
it('FE-STAY-PIN-002: nothing found, a failed search and an empty name all read as not on the map', async () => {
const { result } = renderHook(() => useStayPin());
vi.mocked(findOnMap).mockResolvedValueOnce(null);
act(() => result.current.lookUp('Ryokan', null));
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
expect(result.current.match).toBeNull();
vi.mocked(findOnMap).mockRejectedValueOnce(new Error('down'));
act(() => result.current.lookUp('Ryokan', null));
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
act(() => result.current.lookUp('', ''));
expect(result.current.pin).toEqual({ status: 'missing' });
});
it('FE-STAY-PIN-003: a later lookup or a hand-picked spot wins over an answer still on its way', async () => {
let answer: (m: typeof SPOT | null) => void = () => undefined;
vi.mocked(findOnMap).mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
const { result } = renderHook(() => useStayPin());
act(() => result.current.lookUp('Hotel', 'Somewhere'));
const picked = { lat: 1, lng: 2, label: 'Picked' };
act(() => result.current.pick(picked));
await act(async () => answer(SPOT));
expect(result.current.pin).toEqual({ status: 'found', match: picked });
act(() => result.current.reset());
expect(result.current.pin).toEqual({ status: 'idle' });
});
it('FE-STAY-PIN-004: offline there is nothing to ask, so the review shows nothing', () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
const { result } = renderHook(() => useStayPin());
act(() => result.current.lookUp('Hotel', 'Somewhere'));
expect(result.current.pin).toEqual({ status: 'idle' });
expect(findOnMap).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react';
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
import { findOnMap, type MapMatch, stayQueries } from '../../repo/mapMatchRepo';
import { isEffectivelyOffline } from '../../sync/networkMode';
/**
* Where an imported stay will sit on the map, as the review shows it: not asked
* yet (or offline, where there is nothing to ask), being looked up, found, or
* not found at all.
*/
export type StayPin =
{ status: 'idle' } | { status: 'searching' } | { status: 'found'; match: MapMatch } | { status: 'missing' };
/**
* The map spot of a stay reviewed from an import, looked up before it is saved
* so the review can say whether it will show on the map. A stay the search did
* not find used to be saved without coordinates and simply never appeared.
* A later lookup always wins over an earlier one still on its way.
*/
export function useStayPin() {
const lang = usePlaceLanguage();
const [pin, setPin] = useState<StayPin>({ status: 'idle' });
const ticket = useRef(0);
useEffect(
() => () => {
ticket.current++;
},
[]
);
const lookUp = (name: string | null | undefined, address: string | null | undefined) => {
const mine = ++ticket.current;
const queries = stayQueries(name ?? '', address);
if (isEffectivelyOffline()) {
setPin({ status: 'idle' });
return;
}
if (queries.length === 0) {
setPin({ status: 'missing' });
return;
}
setPin({ status: 'searching' });
findOnMap(queries, lang).then(
(match) => {
if (mine === ticket.current) setPin(match ? { status: 'found', match } : { status: 'missing' });
},
() => {
if (mine === ticket.current) setPin({ status: 'missing' });
}
);
};
return {
pin,
/** The spot to save the stay at, once one is known. */
match: pin.status === 'found' ? pin.match : null,
lookUp,
/** A spot the reader picked by hand, which beats any lookup. */
pick: (match: MapMatch) => {
ticket.current++;
setPin({ status: 'found', match });
},
reset: () => {
ticket.current++;
setPin({ status: 'idle' });
},
};
}

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