Compare commits

..

75 Commits

Author SHA1 Message Date
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
432 changed files with 8198 additions and 1739 deletions
-1
View File
@@ -22,7 +22,6 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY client/package.json ./client/
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
+3 -5
View File
@@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -41,9 +41,7 @@
"shots:promote": "node e2e/screenshots/promote.mjs",
"e2e:report": "playwright show-report",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
"postinstall": "node scripts/patch-maplibre.mjs",
"predev": "node scripts/patch-maplibre.mjs"
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
@@ -94,7 +92,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"maplibre-gl": "6.15.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
+2 -2
View File
@@ -10,7 +10,7 @@
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 165,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1929,
"@typescript-eslint/no-non-null-assertion": 1927,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+16 -1
View File
@@ -661,9 +661,24 @@
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
},
{
"feature": "Settings / Demo account locks",
"hooks": ["src/components/Demo/useDemoLock.ts"],
"desktop": [
"src/components/Settings/AccountDemoLock.tsx",
"src/components/Settings/DisplaySettingsTab.tsx",
"src/components/Settings/IntegrationsTab.tsx",
"src/components/Settings/ai/AiConnectionSection.tsx"
],
"mobile": [
"src/mobile/screens/settings/MAiConnectionSection.tsx",
"src/mobile/screens/settings/MSettingsGeneral.tsx",
"src/mobile/screens/settings/MSettingsMcp.tsx"
]
},
{
"feature": "Settings / Account",
"hooks": ["src/components/Settings/useAccountSettings.ts"],
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
"desktop": ["src/components/Settings/AccountTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
},
-2
View File
@@ -55,7 +55,6 @@
"scripts/check-gl-split.mjs",
"scripts/check-page-pattern.mjs",
"scripts/generate-icons.mjs",
"scripts/patch-maplibre.mjs",
"src/App.test.tsx",
"src/App.tsx",
"src/App.versionHandover.test.tsx",
@@ -409,7 +408,6 @@
"src/components/Journey/usePhotoZoom.ts",
"src/components/Layout/BottomNav.test.tsx",
"src/components/Layout/BottomNav.tsx",
"src/components/Layout/DemoBanner.tsx",
"src/components/Layout/InAppNotificationBell.test.tsx",
"src/components/Layout/InAppNotificationBell.tsx",
"src/components/Layout/MobileTopBar.test.tsx",
-34
View File
@@ -1,34 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
const require = createRequire(import.meta.url)
const packagePath = require.resolve('maplibre-gl/package.json')
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
const loops = [
['of elem.attributes)', 'of Array.from(elem.attributes))'],
['of e.attributes)', 'of Array.from(e.attributes))'],
]
for (const filename of [
'src/util/dom.ts',
'dist/maplibre-gl.js',
'dist/maplibre-gl-dev.js',
'dist/maplibre-gl-csp.js',
'dist/maplibre-gl-csp-dev.js',
]) {
const target = join(dirname(packagePath), filename)
const code = readFileSync(target, 'utf8')
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
const [before, after] = loop
if (code.split(before).length === 1 && code.split(after).length === 2) continue
if (code.split(before).length !== 2 || code.includes(after)) {
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
}
writeFileSync(target, code.replace(before, after))
}
+1
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,
+2 -2
View File
@@ -5,8 +5,8 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"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/PDF/TripPDF.tsx": 1082,
+1 -2
View File
@@ -51,7 +51,6 @@
"components/Journey/journeyMapPopup.ts": 17,
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
@@ -207,7 +206,7 @@
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 21,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
"mobile/screens/settings/MSettingsMap.tsx": 5,
"mobile/screens/settings/MSettingsMcp.tsx": 30,
@@ -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'),
};
}
@@ -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
+4 -1
View File
@@ -7,7 +7,8 @@ import {
RASTER_FALLBACK_TILE_URL,
attributionForTile,
} from '../../constants/mapDefaults'
import { hasWebGL } from '../../utils/webgl'
import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
import { isGpuInitFailure } from './glSupport'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
/** The Leaflet layer maplibre-gl-leaflet hands back. */
@@ -96,6 +97,8 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
layer.addTo(map)
} catch (err) {
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
// The GPU, not a context budget: the GL maps elsewhere in the app switch to Leaflet too.
if (isGpuInitFailure(err)) reportWebGLFailure()
detachBasemapLayer(layer)
return false
}
+18 -6
View File
@@ -1,7 +1,6 @@
import maplibregl from 'maplibre-gl'
import * as maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
import { registerRtlTextPlugin } from '../rtlText'
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
/**
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
@@ -11,9 +10,22 @@ import { registerRtlTextPlugin } from '../rtlText'
* Popup, LngLatBounds — which is why the components take the engine as a prop and
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
* on the provider (projection, accessToken, aroundCenter).
*
* MapLibre 6 ships as ES modules only and has no default export any more, so the
* namespace is what goes out as this module's default. It is a frozen module
* namespace: nothing may assign to it, which is why the accessToken writes stay
* behind their mapbox-only guard.
*/
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
// MapLibre 6 finds its worker next to its own module, which inside a bundle is a
// chunk in /assets/ with no worker beside it. Vite builds the worker into its own
// file and hands back the URL; set before any Map is constructed, the
// maplibre-gl-leaflet bridge included, since it only ever reaches the engine
// together with this module.
maplibregl.setWorkerUrl(workerUrl)
// No RTL text plugin here, unlike mapbox. MapLibre 6.9 shapes Arabic and orders
// bidirectional text itself; the old plugin would replace that implementation, and
// a v6 worker can only load the plugin's UMD build through eval.
export default maplibregl
@@ -1,29 +1,33 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The two lines this fix actually adds. Everything else is a helper: the guard in
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting either registration, swapping an
* argument or dropping the deferred flag left the whole suite green.
* What each engine module does on import. Everything else is a helper: the guard
* in rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting the mapbox registration, swapping an
* argument or dropping the deferred flag left the whole suite green, and so would
* a maplibre engine that forgot its worker URL.
*
* The engines are mocked because importing them for real builds a worker pool
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
*/
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
const { mapboxSet, maplibreSet, maplibreWorker } = vi.hoisted(() => ({
mapboxSet: vi.fn(),
maplibreSet: vi.fn(),
maplibreWorker: vi.fn(),
}));
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
vi.mock('maplibre-gl', () => ({ setRTLTextPlugin: maplibreSet, setWorkerUrl: maplibreWorker }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url', () => ({ default: '/assets/maplibre-worker.js' }));
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
beforeEach(() => {
vi.resetModules();
mapboxSet.mockReset();
maplibreSet.mockReset();
maplibreWorker.mockReset();
});
describe('the GL engines register the RTL text plugin (#2235)', () => {
@@ -37,13 +41,12 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
});
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
it('FE-MAP-RTL-002: importing the maplibre engine registers no plugin', async () => {
await import('./maplibre');
expect(maplibreSet).toHaveBeenCalledTimes(1);
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
// the call stays with each engine instead of going through one wrapper.
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
// MapLibre 6.9 shapes right-to-left text itself. The plugin would replace
// that, and a v6 worker could only run its UMD build through eval.
expect(maplibreSet).not.toHaveBeenCalled();
});
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
@@ -54,13 +57,13 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
expect(engine.default).toBeDefined();
});
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
it('FE-MAP-RTL-004: importing the maplibre engine points it at the bundled worker', async () => {
const engine = await import('./maplibre');
expect(engine.default).toBeDefined();
// Give the rejection a tick to go unhandled if nothing caught it.
await new Promise((resolve) => setTimeout(resolve, 0));
// MapLibre 6 looks for its worker next to its own module, which in a bundle
// is a chunk with no worker beside it. Without this no map draws a tile.
expect(maplibreWorker).toHaveBeenCalledTimes(1);
expect(maplibreWorker).toHaveBeenCalledWith('/assets/maplibre-worker.js');
expect(engine.default.setWorkerUrl).toBe(maplibreWorker);
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Whether a map may be drawn with a GL engine (FE-MAP-GLSUP-*).
*
* MapLibre 6 and Mapbox GL 3 throw out of their Map constructor without WebGL2.
* What is under test is the one decision every GL map goes through: the user's
* provider when it can run here, Leaflet otherwise, and Leaflet from the moment a
* GPU that claimed WebGL2 refuses the context after all.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasWebGL, resetWebGLProbe } from '../../utils/webgl';
import { buildGlMap, isGpuInitFailure, pickGlRenderer, useGlRenderer, useWebGL2 } from './glSupport';
const realGetContext = HTMLCanvasElement.prototype.getContext;
/** A browser whose canvas hands out a webgl2 context, or none. */
function browserWithWebGL2(available: boolean) {
const ctx = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) };
const getContext = vi.fn((type: string) => (available && type === 'webgl2' ? ctx : null));
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext;
return getContext;
}
function gpuError(): Error {
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
return err;
}
beforeEach(() => {
resetWebGLProbe();
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
describe('pickGlRenderer', () => {
it('FE-MAP-GLSUP-001: keeps the GL provider the user picked when WebGL2 is there', () => {
expect(pickGlRenderer('maplibre-gl', '', () => true)).toBe('maplibre-gl');
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => true)).toBe('mapbox-gl');
});
it('FE-MAP-GLSUP-002: draws with Leaflet when the browser has no WebGL2', () => {
expect(pickGlRenderer('maplibre-gl', '', () => false)).toBeNull();
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => false)).toBeNull();
});
it('FE-MAP-GLSUP-003: Mapbox without a token stays on Leaflet, MapLibre needs none', () => {
expect(pickGlRenderer('mapbox-gl', '', () => true)).toBeNull();
expect(pickGlRenderer('maplibre-gl', null, () => true)).toBe('maplibre-gl');
});
it('FE-MAP-GLSUP-004: a Leaflet user never pays for the probe', () => {
const webgl2 = vi.fn(() => true);
expect(pickGlRenderer('leaflet', 'pk.token', webgl2)).toBeNull();
expect(pickGlRenderer(undefined, undefined, webgl2)).toBeNull();
expect(webgl2).not.toHaveBeenCalled();
});
});
describe('isGpuInitFailure', () => {
it('FE-MAP-GLSUP-005: knows both engines’ way of saying the GPU refused', () => {
expect(isGpuInitFailure(gpuError())).toBe(true);
expect(isGpuInitFailure(new Error('Failed to initialize WebGL.'))).toBe(true);
});
it('FE-MAP-GLSUP-006: anything else is not a GPU failure', () => {
expect(isGpuInitFailure(new TypeError('container is null'))).toBe(false);
expect(isGpuInitFailure('Failed to initialize WebGL')).toBe(false);
expect(isGpuInitFailure(null)).toBe(false);
});
});
describe('buildGlMap', () => {
it('FE-MAP-GLSUP-007: hands back the map the engine built', () => {
const map = { remove: vi.fn() };
expect(buildGlMap(() => map)).toBe(map);
});
it('FE-MAP-GLSUP-008: a GPU that claimed WebGL2 and refused it gives null and turns the answer to no', () => {
browserWithWebGL2(true);
expect(hasWebGL()).toBe(true);
expect(
buildGlMap(() => {
throw gpuError();
})
).toBeNull();
expect(hasWebGL()).toBe(false);
});
it('FE-MAP-GLSUP-009: any other error is a bug and is thrown on', () => {
browserWithWebGL2(true);
expect(() =>
buildGlMap(() => {
throw new TypeError('style is undefined');
})
).toThrow('style is undefined');
expect(hasWebGL()).toBe(true);
});
});
describe('useGlRenderer', () => {
it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => {
const getContext = browserWithWebGL2(false);
const { result } = renderHook(() => useGlRenderer('maplibre-gl', ''));
expect(result.current).toBeNull();
expect(getContext.mock.calls.map((call) => call[0])).toEqual(['webgl2']);
});
it('FE-MAP-GLSUP-011: switches every mounted map to Leaflet once a GL map fails to build', () => {
browserWithWebGL2(true);
const trip = renderHook(() => useGlRenderer('maplibre-gl', ''));
const terrain = renderHook(() => useWebGL2());
expect(trip.result.current).toBe('maplibre-gl');
expect(terrain.result.current).toBe(true);
act(() => {
buildGlMap(() => {
throw gpuError();
});
});
expect(trip.result.current).toBeNull();
expect(terrain.result.current).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { useSyncExternalStore } from 'react';
import { hasWebGL, reportWebGLFailure, subscribeWebGL, webGLFailures } from '../../utils/webgl';
import type { GlMapProvider } from './glProviders';
/*
* Whether a map may be drawn with a GL engine at all, for every place that would
* build one: the trip map, the journey maps, the settings preview and the 3D tour.
*
* MapLibre 6 and Mapbox GL 3 both need WebGL2 and throw out of their Map
* constructor without it. A browser that cannot give it gets the Leaflet map with
* the user's tiles instead, never a map that fails to start. Like the GL
* renderers it guards, this module stays free of both SDKs (`check:gl-split`).
*/
/**
* The GL engine to draw with, or null for Leaflet: the user's provider, as long
* as it can run here. Mapbox also needs a token, MapLibre does not.
*/
export function pickGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined,
webgl2: () => boolean = hasWebGL
): GlMapProvider | null {
const wanted: GlMapProvider | null =
provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && mapboxToken ? 'mapbox-gl' : null;
// Asked only for a GL provider: a Leaflet user never pays for the probe's context.
return wanted && webgl2() ? wanted : null;
}
/** Re-renders the caller when a GL map found out that WebGL2 does not work after all. */
function useWebGLFailures(): number {
return useSyncExternalStore(subscribeWebGL, webGLFailures, webGLFailures);
}
/** pickGlRenderer for a component, switching to Leaflet the moment a GL map fails to build. */
export function useGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined
): GlMapProvider | null {
useWebGLFailures();
return pickGlRenderer(provider, mapboxToken);
}
/** Whether 3D (the GL terrain map) can run here, kept current the same way. */
export function useWebGL2(): boolean {
useWebGLFailures();
return hasWebGL();
}
/**
* The GPU refusing a context, as either engine reports it from its Map
* constructor. Matched on the name and message because the class itself lives in
* the maplibre chunk, and Mapbox throws a plain Error.
*/
export function isGpuInitFailure(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return err.name === 'GPUInitializationError' || /failed to initialize webgl/i.test(err.message);
}
/**
* Builds a GL map, the second line of defence behind the probe: a GPU that
* claims WebGL2 and still fails is reported, so every map switches to Leaflet,
* and the caller gets null instead of a throw. Anything else is a bug and is
* thrown on.
*/
export function buildGlMap<T>(construct: () => T): T | null {
try {
return construct();
} catch (err) {
if (!isGpuInitFailure(err)) throw err;
reportWebGLFailure();
return null;
}
}
+10 -8
View File
@@ -7,16 +7,18 @@
* this because their labels are drawn server-side, which is why it only surfaced
* once the default basemap became a vector style.
*
* Both engines expose the fix as an optional plugin and neither loads it by
* default. Registering is global to the engine, so it happens once per engine
* module rather than at each map's construction site.
* Mapbox GL exposes the fix as an optional plugin and does not load it by
* default. Registering is global to the engine, so it happens once in the engine
* module rather than at each map's construction site. MapLibre needed the same
* plugin up to version 5; since 6.9 it shapes and reorders the text itself, and
* its engine module registers nothing.
*
* The call itself stays with the caller: this is one of the places the two engines
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
* module passes its own invocation rather than a shared wrapper guessing an arity.
* The call itself stays with the caller: Mapbox GL 3 takes
* `(url, callback, deferred)` and returns nothing, while MapLibre took
* `(url, lazy)` and returned a promise, so the guard below accepts either rather
* than guessing an arity.
*
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
* Deliberately no getRTLTextPluginStatus() pre-check: reading it made MapLibre
* build its worker pool, which is an eager side effect for a module that is
* imported for its default export, and throws outright under jsdom. Registering
* twice is reported — by a throw or a rejected promise depending on the engine —
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn(), reportWebGLFailure: vi.fn() }))
const { hasWebGL } = await import('../../utils/webgl')
const { renderTripMapImage } = await import('./tripMapImage')
+11 -6
View File
@@ -1,6 +1,7 @@
import { buildRouteOverlay, wrapSvg } from './tripMapSvg'
import { hasWebGL } from '../../utils/webgl'
import { normalizeStyleForProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
import { importChunk } from '../../utils/chunkReload'
@@ -49,18 +50,20 @@ export async function renderTripMapImage(
try {
const gl = (await importChunk(() => import('../Map/engines/maplibre'))).default
container = document.createElement('div')
// A const of its own: the map is built inside a closure, where the let's narrowing is gone.
const box = document.createElement('div')
container = box
// Off-screen rather than hidden: MapLibre needs a laid-out box with real
// dimensions, and `display:none` gives it a zero-sized canvas.
container.style.cssText =
box.style.cssText =
`position:fixed;left:-10000px;top:0;width:${width}px;height:${height}px;pointer-events:none;`
document.body.appendChild(container)
document.body.appendChild(box)
const bounds = new gl.LngLatBounds()
for (const [lat, lng] of points) bounds.extend([lng, lat])
const instance = new gl.Map({
container,
const instance = buildGlMap(() => new gl.Map({
container: box,
style: normalizeStyleForProvider('maplibre-gl', opts.style),
bounds,
fitBoundsOptions: { padding: 42, animate: false },
@@ -70,7 +73,9 @@ export async function renderTripMapImage(
// MapLibre 5 moved the WebGL flags off the top level into this bag.
canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true },
fadeDuration: 0,
})
}))
// A GPU that offered WebGL2 and then refused it: reported, so the app's maps follow.
if (!instance) return null
map = instance
const ready = await new Promise<boolean>(resolve => {
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import AccountDemoLock from './AccountDemoLock';
beforeEach(() => resetAllStores());
function renderCards() {
return render(
<AccountDemoLock>
<button type="button">Save profile</button>
</AccountDemoLock>
);
}
describe('AccountDemoLock', () => {
it('FE-COMP-ACCOUNTDEMOLOCK-001: the shared demo account sees its cards under the lock, with the reason', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
const { container } = renderCards();
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
expect(screen.queryByRole('button', { name: 'Save profile' })).not.toBeInTheDocument();
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
screen.getByRole('button', { hidden: true, name: 'Save profile' })
);
});
it('FE-COMP-ACCOUNTDEMOLOCK-002: the admin of a demo instance gets the cards as they are', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
const { container } = renderCards();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
expect(container.querySelector('[data-demo-lock]')).toBeNull();
});
it('FE-COMP-ACCOUNTDEMOLOCK-003: the demo address on an instance that is not a demo is left alone', () => {
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
renderCards();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
});
});
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import DemoLock from '../Demo/DemoLock';
import { useDemoAccountLock } from '../Demo/useDemoLock';
/**
* The account cards under the demo lock, on the desktop tab and the phone
* screen alike. Every visitor of a demo instance signs in as the one shared
* demo account, so its profile, password, two factor setup and passkeys stay
* on view but out of reach, while the admin of a demo instance and every
* account of a real one get the cards as they are. Both views wrap their
* cards in this, so whom the lock holds and the reason it gives are written
* once.
*/
export default function AccountDemoLock({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const locked = useDemoAccountLock();
return (
<DemoLock locked={locked} reason={t('demo.lock.account')}>
{children}
</DemoLock>
);
}
@@ -395,10 +395,20 @@ describe('AccountTab – MFA', () => {
expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument();
});
it('FE-COMP-ACCOUNT-036: MFA section shows demoBlocked message in demo mode', () => {
seedStore(useAuthStore, { demoMode: true });
it('FE-COMP-ACCOUNT-036: the shared demo account sees the whole tab under the demo lock', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
const { container } = render(<AccountTab />);
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
screen.getByRole('button', { hidden: true, name: /Set up authenticator/ }),
);
});
it('FE-COMP-ACCOUNT-036b: the admin of a demo instance keeps the tab unlocked, 2FA included', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin', mfa_enabled: false }) });
render(<AccountTab />);
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Set up authenticator/ })).toBeInTheDocument();
});
});
@@ -12,6 +12,7 @@ import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
} from './settingsKit'
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
import AccountDemoLock from './AccountDemoLock'
/** The small turning ring a button shows while its request is on the way. */
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
@@ -71,6 +72,8 @@ export default function AccountTab(): React.ReactElement {
return (
<>
{/* The shared demo account: everything here is shown, nothing can be changed. */}
<AccountDemoLock>
<Section title={t('settings.account')} icon={User}>
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
@@ -221,10 +224,7 @@ export default function AccountTab(): React.ReactElement {
</div>
)}
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
{demoMode ? (
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<>
<SettingRows>
<SettingRow
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
@@ -357,12 +357,12 @@ export default function AccountTab(): React.ReactElement {
</div>
</div>
)}
</>
)}
</>
</Section>
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
</AccountDemoLock>
{/* Delete Account Blocked */}
<DialogShell
@@ -483,3 +483,18 @@ describe('DisplaySettingsTab week start (#2029)', () => {
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});
describe('DisplaySettingsTab – the demo account', () => {
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), isLoaded: true });
render(<DisplaySettingsTab />);
expect(screen.getByRole('note')).toHaveTextContent('The demo always runs in English.');
});
it('FE-COMP-DISPLAY-DEMO-002: any other account of a demo instance picks its language freely', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
render(<DisplaySettingsTab />);
expect(screen.queryByRole('note')).not.toBeInTheDocument();
});
});
@@ -18,6 +18,8 @@ import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDes
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
const SELECT_BOX = 'w-[260px] max-w-full'
@@ -25,6 +27,7 @@ const SELECT_BOX = 'w-[260px] max-w-full'
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, save } = useSettingSaver()
const { t, locale } = useTranslation()
const demoLocked = useDemoAccountLock()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
const [langOpen, setLangOpen] = useState(false)
@@ -123,7 +126,9 @@ export default function DisplaySettingsTab(): React.ReactElement {
}
/>
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
The shared demo account always runs in English. */}
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
<SettingRow label={t('settings.language')} stacked>
<div className="hidden sm:block">
<ChoiceChips
@@ -162,6 +167,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
)}
</div>
</SettingRow>
</DemoLock>
{/* Place names (#1799): searches can answer in another language than the app. */}
<SettingRow
@@ -18,10 +18,13 @@ import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, Setting
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
import { useIntegrationGates } from './useIntegrationGates'
import WandererConnectionSection from './WandererConnectionSection'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
const managed = S.managed
const demoLocked = useDemoAccountLock()
return (
<>
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
@@ -42,9 +45,15 @@ export default function IntegrationsTab(): React.ReactElement {
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
one should not have to read past a section about AI assistants. */}
<ApiKeysSection />
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
<ApiKeysSection />
</DemoLock>
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
{S.mcpEnabled && (
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
<IntegrationsMcpSection S={S} />
</DemoLock>
)}
<McpTokenModals S={S} />
<OAuthClientModals S={S} />
</>
@@ -33,6 +33,12 @@ vi.mock('./MapboxPreview', () => ({
),
}));
// jsdom has no WebGL2; these cases are about a browser that has it.
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => true,
}));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
@@ -132,7 +132,7 @@ export default function MapSettingsTab(): React.ReactElement {
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, save: saveMapSettings,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
// One chunk per engine — see components/Map/glLazy.tsx.
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
@@ -318,7 +318,7 @@ export default function MapSettingsTab(): React.ReactElement {
)}
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
{provider !== 'leaflet' ? (
{glPreview ? (
/* A net of its own: the preview is the one place a user flips providers
live, so it is the likeliest chunk to fail — and a broken preview must
not take the rest of the settings tab with it. */
@@ -363,6 +363,7 @@ export default function MapSettingsTab(): React.ReactElement {
})
)}
</div>
{webgl2Missing && <SettingsHint>{t('map.webgl2.fallback')}</SettingsHint>}
<div className="flex justify-end">
<button type="button"
@@ -32,7 +32,7 @@ const mapboxModule = vi.hoisted(() => ({ accessToken: '', Map: mapboxCtor }));
vi.mock('mapbox-gl', () => ({ default: mapboxModule }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl', () => ({ default: { Map: maplibreCtor } }));
vi.mock('maplibre-gl', () => ({ Map: maplibreCtor }));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('../Map/mapboxSetup', () => ({
@@ -45,7 +45,7 @@ vi.mock('../Map/mapboxSetup', () => ({
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup';
import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders';
import mapboxgl from 'mapbox-gl';
import maplibregl from 'maplibre-gl';
import * as maplibregl from 'maplibre-gl';
import GlMapPreviewWithEngine from './MapboxPreview';
// The engine is a prop now, not a module import — that is what keeps mapbox-gl and
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import { fs } from '../shared/DialogShell'
interface Props {
@@ -46,11 +47,13 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
antialias: quality,
}
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: the settings form has switched the preview to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
map.on('load', () => {
@@ -140,4 +140,13 @@ describe('AiConnectionSection', () => {
const { container } = render(<AiConnectionSection />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
});
it('FE-COMP-AI-CONNECTION-008: the shared demo account sees its own connection locked, and no admin hint', async () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
render(<AiConnectionSection />);
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
expect(screen.getByRole('button', { hidden: true, name: 'Connect your own provider' })).toBeDisabled();
});
});
@@ -3,6 +3,8 @@ import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
import DemoLock from '../../Demo/DemoLock';
import { useDemoAccountLock } from '../../Demo/useDemoLock';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { fs } from '../../shared/DialogShell';
@@ -202,6 +204,25 @@ function AssistantBehaviour() {
);
}
/** Where the shared demo account would connect a provider of its own: shown, and locked. */
function LockedOwnConnection() {
const { t } = useTranslation();
return (
<DemoLock locked reason={t('demo.lock.ai')} size="row">
<SettingRows>
<SettingRow
label={t('demo.lock.aiTitle')}
control={
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
{t('assistant.errorAction.connectOwn')}
</button>
}
/>
</SettingRows>
</DemoLock>
);
}
/** The connect panel, opened on purpose or because nothing is connected yet. */
function ConnectPanel({ S }: { S: AiConnections }) {
const { t } = useTranslation();
@@ -254,6 +275,7 @@ export default function AiConnectionSection({
}): React.ReactElement | null {
const { t } = useTranslation();
const S = useAiConnections();
const demoLocked = useDemoAccountLock();
const text = aiSectionText(assistant, parsing);
if (S.view === 'hidden') return null;
const own = S.own;
@@ -277,7 +299,8 @@ export default function AiConnectionSection({
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
{S.view === 'instance' && <InstanceConnection S={S} />}
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{S.view === 'adminOnly' && !demoLocked && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{demoLocked && S.view !== 'loading' && <LockedOwnConnection />}
{S.panel && <ConnectPanel S={S} />}
<SectionFooter S={S} />
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Place } from '../../types';
import { normalizeStyleForProvider } from '../Map/glProviders';
import { useWebGL2 } from '../Map/glSupport';
import { useToast } from '../shared/Toast';
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
@@ -51,6 +52,11 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
}, [settings]);
const previewPlaces = useMemo((): Place[] => [previewPlace()], []);
// Both GL engines need WebGL2. Without it the preview shows the Leaflet map the
// trip planner falls back to, and both shells say why.
const webgl2 = useWebGL2();
const glPreview = provider !== 'leaflet' && webgl2;
const webgl2Missing = provider !== 'leaflet' && !webgl2;
const save = async (): Promise<void> => {
setSaving(true);
@@ -105,6 +111,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
mapboxQuality,
setMapboxQuality,
previewPlaces,
glPreview,
webgl2Missing,
save,
};
}
@@ -1,6 +1,7 @@
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
import { buildGlMap } from '../../Map/glSupport';
import { isTourLayerHit } from '../../Map/mapRasterOverlay';
import {
addTerrainLayers,
@@ -110,8 +111,11 @@ export function useTerrainMap({ scene, baseLayer, data, exaggeration }: Options)
let map: MapLibreMap | null = null;
void import('../../Map/engines/maplibre').then(({ default: engine }) => {
if (cancelled) return;
map = createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle });
setGl({ map, engine });
// Null when the GPU refused WebGL2. The report switches the planner back to the 2D map.
map = buildGlMap(() =>
createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle })
);
if (map) setGl({ map, engine });
});
return () => {
cancelled = true;
@@ -14,7 +14,7 @@ import { useInAppNotificationStore } from '../../../store/inAppNotificationStore
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
import type { TripCardBadge } from '../../../api/client'
import DemoBanner from '../../../components/Layout/DemoBanner'
import DemoWelcome from '../../../components/Demo/DemoWelcome'
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
import PluginWidgets from '../../../components/Plugins/PluginWidgets'
import { entityGradient } from '../../../utils/gradients'
@@ -247,7 +247,7 @@ export default function MDashboard(): React.ReactElement {
<MUserMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
{demoMode && <DemoBanner />}
{demoMode && <DemoWelcome />}
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
{loadError && (
@@ -2,8 +2,11 @@
import userEvent from '@testing-library/user-event';
import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../tests/helpers/factories';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useAssistantStore } from '../../../store/assistantStore';
import { useAuthStore } from '../../../store/authStore';
const repo = vi.hoisted(() => ({
status: vi.fn(),
@@ -83,4 +86,20 @@ describe('MAiConnectionSection', () => {
expect(await screen.findByText('What would you like to chat with?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Paste an API key/ })).toBeInTheDocument();
});
it('FE-MOB-AI-DEMO-001: the shared demo account sees the place for its own connection locked', async () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
render(<MAiConnectionSection />);
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
});
it('FE-MOB-AI-DEMO-002: any other account keeps the admin hint', async () => {
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
render(<MAiConnectionSection />);
expect(await screen.findByText('Your admin sets this up.')).toBeInTheDocument();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
});
});
@@ -2,6 +2,8 @@ import type { AiConnectionView } from '@trek/shared';
import { ChevronDown, Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
import React, { useState } from 'react';
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../../components/Assistant/useAssistantMapFollow';
import DemoLock from '../../../components/Demo/DemoLock';
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock';
import AiConnectPanel from '../../../components/Settings/ai/AiConnectPanel';
import { aiSectionText, CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
import { useAiConnections } from '../../../components/Settings/ai/useAiConnections';
@@ -117,6 +119,30 @@ function MInstanceConnection({ S }: { S: AiConnections }) {
);
}
/**
* Under the connection: the hint that only the admin connects one, or, for the
* shared demo account, the locked place where its own would go.
*/
function MOwnConnectionNote({ view }: { view: AiConnections['view'] }) {
const { t } = useTranslation();
const demoLocked = useDemoAccountLock();
if (view === 'loading') return null;
if (!demoLocked) return view === 'adminOnly' ? <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint> : null;
return (
<DemoLock locked reason={t('demo.lock.ai')} size="row" className="mt-3">
<MSetRow
first
label={t('demo.lock.aiTitle')}
trailing={
<MSetButton variant="ghost" disabled>
{t('assistant.errorAction.connectOwn')}
</MSetButton>
}
/>
</DemoLock>
);
}
/** Whether the trip map follows the assistant, the phone's row for the shared switch. */
function MAssistantBehaviour() {
const { t } = useTranslation();
@@ -180,7 +206,7 @@ export default function MAiConnectionSection({
{S.view === 'own' && S.own && <MOwnConnection S={S} own={S.own} />}
{S.view === 'instance' && <MInstanceConnection S={S} />}
{assistant && (S.view === 'own' || S.view === 'instance') && <MAssistantBehaviour />}
{S.view === 'adminOnly' && <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint>}
<MOwnConnectionNote view={S.view} />
{S.panel && (
<div
@@ -22,6 +22,7 @@ import MConfirmSheet from './MConfirmSheet'
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
import AccountDemoLock from '../../../components/Settings/AccountDemoLock'
/**
* "Account" section — AccountTab parity: profile + avatar, password change,
@@ -78,6 +79,8 @@ export default function MSettingsAccount() {
return (
<>
{/* The shared demo account: everything here is shown, nothing can be changed. */}
<AccountDemoLock>
{/* ── Profile ─────────────────────────────────────────────── */}
<MSetCard title={t('settings.account')} icon={User}>
<div className="mb-4 flex items-center gap-4">
@@ -185,10 +188,7 @@ export default function MSettingsAccount() {
)}
<p className="text-[0.75rem] leading-relaxed text-m-muted">{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="mt-2 text-[0.75rem] font-semibold text-[color:var(--m-st-pending)]">{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<>
<p className="mt-2 text-[0.78125rem] font-bold text-m-ink">
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p>
@@ -287,11 +287,11 @@ export default function MSettingsAccount() {
</div>
</div>
)}
</>
)}
</>
</MSetCard>
<MPasskeysCard demoMode={demoMode} />
</AccountDemoLock>
<MConfirmSheet
open={showDeleteConfirm === 'blocked'}
@@ -12,6 +12,8 @@ import { MSetCard, MSetEyebrow, MSetHint, MSetSelectRow, MSetSegments, MSetRow }
import MToggle from '../../components/MToggle'
import MSetPickerSheet from './MSetPickerSheet'
import { preferredNavAppOptions } from '../../../components/Planner/placeNavigation'
import DemoLock from '../../../components/Demo/DemoLock'
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
/**
* "General" settings section — the demo's Language & region and Travel & map
@@ -20,6 +22,7 @@ import { preferredNavAppOptions } from '../../../components/Planner/placeNavigat
export default function MSettingsGeneral() {
const { t, locale } = useTranslation()
const { settings, save } = useSettingSaver()
const demoLocked = useDemoAccountLock()
const [currencyOpen, setCurrencyOpen] = useState(false)
const [langOpen, setLangOpen] = useState(false)
const [startTabOpen, setStartTabOpen] = useState(false)
@@ -121,8 +124,11 @@ export default function MSettingsGeneral() {
<MSetEyebrow className="mb-[5px]">{t('settings.currency')}</MSetEyebrow>
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
{/* The shared demo account always runs in English. */}
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
</DemoLock>
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.temperature')}</MSetEyebrow>
<MSetSegments
@@ -22,7 +22,7 @@ export default function MSettingsMap() {
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, save,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
@@ -170,7 +170,7 @@ export default function MSettingsMap() {
)}
<div className="relative mt-3 h-[200px] w-full overflow-hidden rounded-xl">
{provider !== 'leaflet' ? (
{glPreview ? (
/* See MapSettingsTab: the preview gets its own net. */
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-m-card" />}>
<Suspense fallback={<div className="h-full w-full bg-m-card animate-pulse" />}>
@@ -208,6 +208,7 @@ export default function MSettingsMap() {
})
)}
</div>
{webgl2Missing && <MSetHint>{t('map.webgl2.fallback')}</MSetHint>}
<MSetButton className="mt-3" onClick={save} disabled={saving}>
<Save size={14} />
@@ -8,6 +8,8 @@ import MChip from '../../components/MChip'
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet'
import MSegmented from '../../components/MSegmented'
import DemoLock from '../../../components/Demo/DemoLock'
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
function CopyButton({ text, copied, onCopy }: { text: string; copied: boolean; onCopy: (text: string) => void }) {
return (
@@ -37,6 +39,7 @@ function CodeBlock({ children }: { children: string }) {
*/
export default function MSettingsMcp() {
const { t, locale } = useTranslation()
const demoLocked = useDemoAccountLock()
const {
activeMcpTab: activeTab,
setActiveMcpTab: setActiveTab,
@@ -119,6 +122,7 @@ export default function MSettingsMcp() {
)
return (
<DemoLock locked={demoLocked} reason={t('demo.lock.mcp')}>
<MSetCard title={t('settings.mcp.title')} icon={Terminal} className="mt-3">
<MSetEyebrow className="mb-[5px]">{t('settings.mcp.endpoint')}</MSetEyebrow>
<div className="flex items-center gap-2">
@@ -518,5 +522,6 @@ export default function MSettingsMcp() {
onConfirm={() => tokenDeleteId !== null && deleteToken(tokenDeleteId)}
/>
</MSetCard>
</DemoLock>
)
}
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import DemoBanner from '../components/Layout/DemoBanner'
import DemoWelcome from '../components/Demo/DemoWelcome'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
@@ -138,7 +138,7 @@ function DashboardPageDesktop(): React.ReactElement {
<Navbar />
<HelpAnchor id="dashboard" />
<div className="trek-dash trek-dash-shell">
{demoMode && <DemoBanner />}
{demoMode && <DemoWelcome />}
<div className="trek-dash-scroll">
<MobileTopBar />
<main className="page" data-no-sidebar={sidebarVisible ? undefined : 'true'}>
@@ -5,11 +5,13 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/
import { idleLegVariants, idleRoundHikes } from '../../../tests/helpers/tourPlannerStub';
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
import type { MapViewProps } from '../../components/Map/mapViewContract';
import { ToastContainer } from '../../components/shared/Toast';
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTourMapViewportStore } from '../../store/tourMapViewportStore';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
type Handler = (event?: unknown) => void;
@@ -75,6 +77,12 @@ const stage = vi.hoisted(() => {
});
vi.mock('../../components/Map/engines/maplibre', () => ({ default: stage.engine }));
// jsdom has no WebGL2: the stage is told it is there, except where a case takes it away.
const webgl = vi.hoisted(() => ({ available: true }));
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => webgl.available,
}));
vi.mock('../../components/Map/MapViewAuto', () => ({
MapViewAuto: (props: MapViewProps) => {
stage.flatMap.props = props;
@@ -185,6 +193,8 @@ async function openTerrain() {
}
beforeEach(() => {
webgl.available = true;
resetWebGLProbe();
window.sessionStorage.clear();
useTourPlaceToolStore.setState(initialTourPlaceToolState());
stage.listeners.clear();
@@ -928,3 +938,44 @@ describe('TourPlannerMapStage', () => {
});
});
});
describe('3D without WebGL2', () => {
// The stage translates with the t it is handed, and stageProps hands it one that returns the key.
const NO_3D = 'map.webgl2.terrain';
function renderWithToasts(tourPlanner: Planner) {
return render(
<>
<ToastContainer />
<TourPlannerMapStage {...stageProps(tourPlanner, true)} />
</>
);
}
it('FE-PAGE-TOURSTAGE-WEBGL-001: the 3D switch keeps the 2D map and says why', async () => {
webgl.available = false;
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).not.toHaveBeenCalled();
});
it('FE-PAGE-TOURSTAGE-WEBGL-002: a GPU that refuses the terrain context brings the 2D map back', async () => {
stage.engine.Map.mockImplementationOnce(function () {
webgl.available = false;
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
throw err;
});
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).toHaveBeenCalledTimes(1);
});
});
@@ -5,11 +5,13 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { useWebGL2 } from '../../components/Map/glSupport';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
import LocationSelect from '../../components/Planner/LocationSelect';
import { useToast } from '../../components/shared/Toast';
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
@@ -25,6 +27,7 @@ import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddle
import { useSettingsStore } from '../../store/settingsStore';
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
import { publishTourMapViewport } from '../../store/tourMapViewportStore';
import { subscribeWebGL } from '../../utils/webgl';
import { useFerrataAdopt } from './useFerrataAdopt';
import { useOneCornerCard } from './useOneCornerCard';
import { useTourMapPoints } from './useTourMapPoints';
@@ -112,7 +115,22 @@ export function TourPlannerMapStage({
paddleChecklist,
assistantRoutes,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
const [terrainAsked, setTerrain3d] = useState(false);
// The terrain is a MapLibre map, so it needs WebGL2. Without it, or once its GPU
// refused the context, the stage stays on the 2D map and says why.
const webgl2 = useWebGL2();
const terrain3d = terrainAsked && webgl2;
const toast = useToast();
const openTerrain = useCallback(() => {
if (webgl2) setTerrain3d(true);
else toast.info(t('map.webgl2.terrain'));
}, [webgl2, toast, t]);
// A GPU that offered WebGL2 and then refused the terrain's context: the 2D map is
// back by itself, and this says why.
useEffect(() => {
if (!terrainAsked) return;
return subscribeWebGL(() => toast.info(t('map.webgl2.terrain')));
}, [terrainAsked, toast, t]);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
const [terrainLegend, setTerrainLegend] = useState<TerrainLegendLayout | null>(null);
@@ -287,7 +305,7 @@ export function TourPlannerMapStage({
viewBaseLayer={baseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
tourLayers={tourLayers}
onToggleTerrain3d={() => setTerrain3d(true)}
onToggleTerrain3d={openTerrain}
ferrataLayer={ferrataLayer}
onViewportChange={viewportChanged}
placeTool={placeTool}
+24
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
alongShortestArc,
computeMapViewport,
MAX_ZOOM_GL,
MAX_ZOOM_RASTER,
@@ -263,3 +264,26 @@ describe('computeMapViewport', () => {
expect(withPadding.zoom).toBe(equivalent.zoom)
})
})
describe('alongShortestArc', () => {
const FIJI: [number, number] = [-18.14, 178.44]
const TONGA: [number, number] = [-21.14, -175.2]
const SAMOA: [number, number] = [-13.83, -171.76]
it('carries the places past the antimeridian on beyond 180, so a min-max box between them is the short one', () => {
const lngs = alongShortestArc([FIJI, TONGA, SAMOA]).map(([, lng]) => Math.round(lng * 100) / 100)
// From Fiji east across the date line: under ten degrees wide, not the 350 the other way round.
expect(lngs).toEqual([178.44, 184.8, 188.24])
})
it('leaves places that do not cross the antimeridian as they are', () => {
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
expect(alongShortestArc(cafes)).toEqual(cafes)
})
it('has nothing to lay out for no places', () => {
expect(alongShortestArc([])).toEqual([])
})
})
+12
View File
@@ -146,6 +146,18 @@ function lngExtent(lngs: number[], wrapsWorld: boolean): { west: number; east: n
return { west, east, span: east - west }
}
/**
* `[lat, lng]` points with their longitudes laid along the narrowest arc that holds them all
* (lngExtent), for a renderer that wraps the world: a point past the antimeridian goes on
* beyond 180 instead of starting over at -180. A bounding box built from them by plain min and max (MapLibre's and Mapbox's
* extend) then spans the few degrees from Fiji to Samoa, not the rest of the world.
*/
export function alongShortestArc(points: readonly [number, number][]): [number, number][] {
if (points.length === 0) return []
const { west } = lngExtent(points.map(([, lng]) => lng), true)
return points.map(([lat, lng]) => [lat, lng < west ? lng + 360 : lng])
}
/** Zoom at which a span covering `fraction` of the world fills `px` pixels. */
function zoomForFraction(px: number, tileSize: number, fraction: number, maxZoom: number): number {
// A zero fraction (identical coordinates) would divide by zero and yield Infinity.
+5 -5
View File
@@ -42,15 +42,15 @@ describe('hasWebGL', () => {
expect(hasWebGL()).toBe(false)
})
it('FE-UTIL-WEBGL-003: falls back to webgl1 when only webgl2 is refused', () => {
// Exactly the order maplibre-gl asks in. A stricter probe would black out
// maps that would in fact have drawn.
it('FE-UTIL-WEBGL-003: says no when only webgl1 is on offer', () => {
// MapLibre 6 asks for webgl2 and nothing else. A probe that settled for
// webgl1 would wave the map through to a GPUInitializationError.
const { ctx } = fakeContext()
const getContext = vi.fn((type: string) => (type === 'webgl2' ? null : ctx))
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext
expect(hasWebGL()).toBe(true)
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2', 'webgl'])
expect(hasWebGL()).toBe(false)
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2'])
})
it('FE-UTIL-WEBGL-004: gives the probe context straight back', () => {
+41 -7
View File
@@ -1,5 +1,6 @@
/**
* Whether this browser will hand MapLibre a WebGL context.
* Whether this browser will hand a GL map a WebGL2 context. MapLibre 6 and
* Mapbox GL 3 both ask for webgl2 and nothing else.
*
* The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
* its GL context inside the Map constructor: no context, no map, and the throw
@@ -28,7 +29,39 @@ export function hasWebGL(): boolean {
return probed
}
/** Test seam. Nothing in the app clears this: the answer cannot change mid-session. */
const listeners = new Set<() => void>()
let failures = 0
/**
* A GL map that failed to build although the probe said yes: a GPU that offers
* WebGL2 and then cannot create the context, or a context budget that ran out.
* From here on the answer is no, and every map that asked is told, so it can
* redraw itself without WebGL instead of waiting for the next page load.
*/
export function reportWebGLFailure(): void {
if (probed === false) return
probed = false
failures++
for (const listener of listeners) listener()
}
/** For useSyncExternalStore: tells a map when reportWebGLFailure changed the answer. */
export function subscribeWebGL(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** For useSyncExternalStore: changes each time reportWebGLFailure turned the answer to no. */
export function webGLFailures(): number {
return failures
}
/**
* Test seam. The app never clears this, it only turns it to no through
* reportWebGLFailure: a browser does not gain WebGL2 mid-session.
*/
export function resetWebGLProbe(): void {
probed = null
}
@@ -45,12 +78,13 @@ function probe(): boolean {
canvas.width = 1
canvas.height = 1
// Exactly the order maplibre-gl asks in (Map._setupPainter): webgl2 first,
// webgl1 second. A stricter probe would black out maps that would in fact
// have drawn; a looser one would let the throw through anyway.
const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as WebGLRenderingContext | null
// Exactly what maplibre-gl asks for (Map._setupPainter): WebGL2 and nothing
// else, since MapLibre 6 dropped WebGL1. A browser that only offers WebGL1
// passes a looser probe and then throws GPUInitializationError out of the Map
// constructor, which is the crash this probe exists to prevent.
const gl = canvas.getContext('webgl2')
if (!gl) {
console.warn('[basemap] this browser refuses a WebGL context, vector basemaps are unavailable')
console.warn('[basemap] this browser refuses a WebGL2 context, vector basemaps are unavailable')
return false
}
+15 -9
View File
@@ -1,8 +1,14 @@
import { describe, expect, it } from 'vitest'
import maplibregl from 'maplibre-gl'
import { AttributionControl, type Map } from 'maplibre-gl'
/**
* GHSA-jrc7-96c5-q579: MapLibre's attribution sanitizer let a second dangerous
* attribute through after removing the first, so a tile source's attribution
* could run script. TREK carried a patch for this on 5.x; 6.x replaced the
* sanitizer with an allow list, and these cases hold the upgrade to it.
*/
function attribution(html: string, fromSource = false) {
const control = new maplibregl.AttributionControl({
const control = new AttributionControl({
compact: false,
customAttribution: fromSource ? undefined : html,
})
@@ -16,17 +22,17 @@ function attribution(html: string, fromSource = false) {
on: () => {},
off: () => {},
getCanvasContainer: () => document.createElement('div'),
} as unknown as maplibregl.Map
} as unknown as Map
return control.onAdd(map)
}
describe('MapLibre attribution sanitizer backport', () => {
it.each([false, true])('removes adjacent event handlers (source: %s)', fromSource => {
describe('MapLibre attribution sanitizer', () => {
it.each([false, true])('leaves no event handler behind (source: %s)', fromSource => {
const container = attribution('<details open onload="1" ontoggle="alert(1)">Credit</details>', fromSource)
const details = container.querySelector('.maplibregl-ctrl-attrib-inner details')!
expect(details.hasAttribute('onload')).toBe(false)
expect(details.hasAttribute('ontoggle')).toBe(false)
expect(details.textContent).toBe('Credit')
const inner = container.querySelector('.maplibregl-ctrl-attrib-inner')!
// <details> is not on the allow list, so it goes with its subtree.
expect(inner.querySelector('details')).toBeNull()
expect(inner.querySelectorAll('[onload], [ontoggle]')).toHaveLength(0)
})
it('removes consecutive dangerous URLs and handlers in nested markup', () => {
@@ -173,11 +173,11 @@ describe('MDashboard', () => {
expect(screen.queryByText('dashboard.emptyTitle')).not.toBeInTheDocument();
});
it('FE-MOB-DASH-008: demo mode adds the banner', () => {
it('FE-MOB-DASH-008: demo mode opens the demo welcome', () => {
mocks.dash = buildDash({ demoMode: true });
render(<MDashboard />);
expect(screen.getByText(/demo/i)).toBeInTheDocument();
expect(screen.getByRole('dialog')).toHaveTextContent('Welcome to TREK');
});
it('FE-MOB-DASH-009: unread notifications mark the bell and it opens the list', () => {
@@ -385,12 +385,13 @@ describe('MSettingsAccount – MFA', () => {
await screen.findByText('JBSWY3DPEHPK3PXP');
}
it('FE-MOB-SETACC-023: demo mode replaces the controls with the demo notice', () => {
seedStore(useAuthStore, { demoMode: true });
it('FE-MOB-SETACC-023: the shared demo account sees the controls under the demo lock', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
renderAccount();
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
expect(screen.queryByRole('button', { name: 'Set up authenticator' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { hidden: true, name: 'Set up authenticator' })).toBeInTheDocument();
});
it('FE-MOB-SETACC-024: the ?mfa=required entry shows the policy banner', () => {
@@ -29,6 +29,11 @@ vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({
/>
),
}));
// jsdom has no WebGL2; these cases are about a browser that has it.
vi.mock('../../../../src/utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/utils/webgl')>()),
hasWebGL: () => true,
}));
const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
+42 -71
View File
@@ -30,7 +30,6 @@
"client": {
"name": "@trek/client",
"version": "4.3.4",
"hasInstallScript": true,
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
@@ -80,7 +79,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",
@@ -4971,44 +4970,35 @@
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.3.0.tgz",
"integrity": "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.3.0.tgz",
"integrity": "sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-leaflet": {
@@ -5023,12 +5013,12 @@
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"version": "26.4.4",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
@@ -5041,16 +5031,10 @@
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
@@ -5067,18 +5051,6 @@
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
"integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@microsoft/tsdoc": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
@@ -13603,9 +13575,9 @@
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
"integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
"license": "ISC"
},
"node_modules/ecdsa-sig-formatter": {
@@ -17490,27 +17462,26 @@
]
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.15.0.tgz",
"integrity": "sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@mapbox/tiny-sdf": "^2.3.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.3.0",
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
"@maplibre/mlt": "^1.3.0",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"bidi-js": "^1.1.0",
"earcut": "^3.2.4",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
@@ -19398,9 +19369,9 @@
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
@@ -1,5 +1,9 @@
import { currentTimestampKysely } from '../dialect/kysely-functions';
import { Addons } from '../entities/Addons.entity';
import { AppSettings } from '../entities/AppSettings.entity';
import { DocumentProviders } from '../entities/DocumentProviders.entity';
import { PhotoProviders } from '../entities/PhotoProviders.entity';
import { Settings } from '../entities/Settings.entity';
import { Trips } from '../entities/Trips.entity';
import { Users } from '../entities/Users.entity';
import type { DB } from '../kysely/db';
@@ -36,6 +40,15 @@ export interface DemoInstanceKeyRow {
value: string | null;
}
/** The states `applyAddonStates` writes: three shelves and the switches under them in `app_settings`. */
export interface DemoAddonStates {
/** Each shelf names the rows it switches on and the rows it switches off. */
readonly addons: { readonly on: readonly string[]; readonly off: readonly string[] };
readonly photoProviders: { readonly on: readonly string[]; readonly off: readonly string[] };
readonly documentProviders: { readonly on: readonly string[]; readonly off: readonly string[] };
readonly appSettings: Readonly<Record<string, string>>;
}
/**
* DMS9's 13-column `places` insert tuple — kept as a tuple, not an object,
* so `demo-seed.ts`'s three ~30-row example-trip data arrays (Tokyo/Kyoto,
@@ -400,4 +413,48 @@ export class DemoRepository {
await repo.upsert({ key: row.key, value: row.value }, { onConflictFields: ['key'], onConflictAction: 'merge' });
}
}
// ---------------------------------------------------------------------
// The fixed demo set (demo-addon-policy.ts), applied by the seed and the reset
// ---------------------------------------------------------------------
/**
* Writes the demo's addon states in one transaction, so a reset that fails
* halfway does not leave half the shelf switched. One UPDATE per shelf and
* state (generic inherited `nativeUpdate` over `id IN (...)`, the statement
* `setEnabled` issues for one row) and one upsert for all the switches
* (`AppSettingsRepository#setValue`'s), however long the lists are. A named
* row the database does not hold, like an empty list, is an update that
* matches nothing, never an insert: the seeders own which rows exist.
*/
async applyAddonStates(states: DemoAddonStates): Promise<void> {
this.validateRequestContext();
const switches = Object.entries(states.appSettings).map(([key, value]) => ({ key, value }));
await this.em.transactional(async () => {
const addons = this.em.getRepository(Addons);
await addons.nativeUpdate({ id: { $in: [...states.addons.on] } }, { enabled: true });
await addons.nativeUpdate({ id: { $in: [...states.addons.off] } }, { enabled: false });
const photos = this.em.getRepository(PhotoProviders);
await photos.nativeUpdate({ id: { $in: [...states.photoProviders.on] } }, { enabled: 1 });
await photos.nativeUpdate({ id: { $in: [...states.photoProviders.off] } }, { enabled: 0 });
const documents = this.em.getRepository(DocumentProviders);
await documents.nativeUpdate({ id: { $in: [...states.documentProviders.on] } }, { enabled: 1 });
await documents.nativeUpdate({ id: { $in: [...states.documentProviders.off] } }, { enabled: 0 });
await this.em
.getRepository(AppSettings)
.upsertMany(switches, { onConflictFields: ['key'], onConflictAction: 'merge' });
});
}
/**
* A user's own settings rows, written as given in one statement (the demo
* account's fixed language). The upsert `SettingsRepository#upsertForUser`
* issues for one key, for every row at once.
*/
async setUserSettings(userId: number, rows: readonly { key: string; value: string }[]): Promise<void> {
await this.em.getRepository(Settings).upsertMany(
rows.map(({ key, value }) => ({ user: userId, key, value })),
{ onConflictFields: ['user', 'key'], onConflictAction: 'merge' },
);
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { DemoAddonStates, DemoRepository } from '../db/repositories/DemoRepository';
/**
* What a demo instance offers, whatever its admin switched during the hour.
*
* The demo shows everything TREK can do on its own: every trip, global and
* integration addon is on. What stays off is what needs a server of the
* visitor's own, which a public demo has no address for and no business
* reaching: the document sync providers, the wanderer connection under Tours,
* the photo providers under Journey, AirTrail and Dawarich. The AI features
* (the assistant, AI parsing) and the MCP server stay off as well: they spend
* the instance's AI credit or hand out tokens to a shared account, so they are
* a feature of a self-hosted install, not of the public demo. So does the
* legacy `memories` row, which nothing reads any more.
*
* Each list names its rows outright instead of "everything but", so a release
* that adds an addon has to decide where it goes: the policy test fails on a
* seeded row that is in neither list. The demo seed applies this on every boot
* and after every reopen, and the hourly reset applies it once more after the
* swap, so the baseline cannot bring back an admin's toggle.
*/
export const DEMO_ADDON_POLICY = {
/** Rows of the `addons` table. */
addons: {
on: [
// Trip addons
'packing',
'budget',
'documents',
'collab',
'roadtrip',
'tours',
// Global addons
'vacay',
'atlas',
'journey',
'collections',
// Integrations TREK runs by itself
'naver_list_import',
],
off: [
'airtrail',
'dawarich',
'mcp',
'llm_parsing',
'ai_assistant',
// The trip Photos addon of old releases. Its migration seeds the row
// switched off and nothing removes it; the client reads Photos from the
// photo providers (addonStore's isEnabled) and the admin panel keeps the
// row out of its trip list, so it switches nothing.
'memories',
],
},
/** Rows of `photo_providers`, Journey's shelf. */
photoProviders: {
on: [],
off: ['immich', 'synologyphotos'],
},
/** Rows of `document_providers`, the Documents addon's shelf. */
documentProviders: {
on: [],
off: ['paperless', 'papra', 'nextcloud', 'opencloud', 'synologydrive'],
},
/**
* The switches that sit under an addon in the admin panel, stored in
* `app_settings`. wanderer is the one under Tours that reaches a server of
* the visitor's own; the rest are on.
*/
appSettings: {
tours_wanderer_enabled: 'false',
tours_hiking_map_enabled: 'true',
tours_cycle_map_enabled: 'true',
ski_routing_enabled: 'true',
bag_tracking_enabled: 'true',
collab_chat_enabled: 'true',
collab_notes_enabled: 'true',
collab_links_enabled: 'true',
collab_polls_enabled: 'true',
collab_whatsnext_enabled: 'true',
},
} as const satisfies DemoAddonStates;
/** Puts the instance's addon states to the demo set, in one transaction. A row the database does not hold is skipped. */
export async function applyDemoAddonPolicy(
demo: DemoRepository,
policy: DemoAddonStates = DEMO_ADDON_POLICY,
): Promise<void> {
await demo.applyAddonStates(policy);
}
+11
View File
@@ -8,7 +8,9 @@ import {
} from '../db/repositories/DemoRepository';
import type { CarriedUserSessionRow } from '../db/repositories/UserSessions.repository';
import { dbNow } from '../db/types';
import { logError } from '../nest/audit/audit-log.logger';
import { DatabaseConnectionLostError, type DatabaseBackupStrategy } from '../nest/database/database-backup.interface';
import { applyDemoAddonPolicy } from './demo-addon-policy';
import { RequestContext, type EntityManager } from '@mikro-orm/core';
import fs from 'fs';
@@ -155,6 +157,15 @@ async function resetDemoUser(database: DatabaseBackupStrategy): Promise<void> {
console.error('[Demo Reset] Failed to restore the active sessions:', e instanceof Error ? e.message : e);
}
}
// The reopen's schema bootstrap ran the demo seed, which applies the demo
// addon set already. Applied here once more so the reset itself states
// what the demo offers after the hour, whatever the baseline holds.
try {
await applyDemoAddonPolicy(demoFresh);
} catch (e: unknown) {
logError(`[Demo Reset] Failed to apply the demo addon set: ${e instanceof Error ? e.message : String(e)}`);
}
});
console.log('[Demo Reset] Database restored from baseline');
+23 -1
View File
@@ -1,6 +1,8 @@
import { readEnv } from '../app-config';
import { DemoRepository, type NewDemoPlaceRow } from '../db/repositories/DemoRepository';
import { DEMO_PASS } from '../nest/common/demo';
import { logInfo } from '../nest/audit/audit-log.logger';
import { DEMO_ACCOUNT_SETTINGS, DEMO_PASS } from '../nest/common/demo';
import { applyDemoAddonPolicy } from './demo-addon-policy';
// Static like in demo-reset.job.ts: the module top is inert, everything that
// touches the database happens inside the functions.
import { markExampleTripsSeeded, requireEntityManager } from './demo-reset';
@@ -53,6 +55,16 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> {
// Disable registration in demo mode
await demo.setAllowRegistrationFalse();
// The fixed demo set: the addons every visitor can try, and the demo
// account's language. This runs on every boot and again after every reopen
// (the hourly reset included), so neither an admin's toggle nor an old
// baseline outlasts the hour.
await applyDemoAddonPolicy(demo);
await demo.setUserSettings(
demoId,
Object.entries(DEMO_ACCOUNT_SETTINGS).map(([key, value]) => ({ key, value })),
);
// Check if admin already has example trips
const adminTripCount = await demo.countTripsByUser(adminId);
if (adminTripCount > 0) {
@@ -61,6 +73,16 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> {
return { adminId, demoId };
}
// A demo whose content belongs to the demo account itself (built over the
// API, then saved as the baseline) is left alone. Without this the old
// example trips would come back on every reopen, the hourly reset included,
// and the mark below would save them into the next baseline.
const demoTripCount = await demo.countTripsByUser(demoId);
if (demoTripCount > 0) {
logInfo('[Demo] The demo account owns its own trips, skipping the example trips');
return { adminId, demoId };
}
console.log('[Demo] Seeding example trips...');
await seedExampleTrips(demo, adminId, demoId);
@@ -9,7 +9,7 @@ import { dbNow } from '../../db/types';
import { logError, logInfo } from '../audit/audit-log.logger';
import { withRequestContext } from '../database/request-context';
import { LlmParseService } from '../llm-parse/llm-parse.service';
import { StorageService } from '../storage/storage.service';
import { ASSISTANT_FILES_PREFIX, StorageService } from '../storage/storage.service';
import type { AgentToolRun } from './assistant-agent';
import { NO_SUCH_ATTACHMENT, NO_TEXT, type AttachedTurn } from './assistant-attachment-tools';
import { AssistantKeepFileService } from './assistant-keep-file.service';
@@ -32,9 +32,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Where the bytes sit: under the trip files' category, which is never served
* by path (`/uploads/files` refuses every request), in a folder of their own.
* Storage names the folder, so a demo instance still deletes these bytes.
*/
const STORAGE_CATEGORY = 'files';
const KEY_PREFIX = 'assistant/';
const KEY_PREFIX = ASSISTANT_FILES_PREFIX;
/** The most text kept of one file; a model is sent a window of it, never all of it. */
const MAX_STORED_TEXT_CHARS = 200_000;
+10 -1
View File
@@ -6,7 +6,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { currentSessionId, decodeSessionClaims } from '../auth-core/jwt-verify';
import { MfaExempt } from '../auth-core/mfa-policy.guard';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { DEMO_ACCOUNT_REFUSAL, DEMO_WRITE_ERROR, isDemoWriteBlocked, refuseForDemoAccount } from '../common/demo-write';
import { ManagedForbidden } from '../common/managed';
import { RateLimitService } from '../common/rate-limit.service';
import { sessionClientFrom } from '../sessions/sessions.service';
@@ -194,6 +194,11 @@ export class AuthController {
@Put('me/settings')
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
// The name and the address belong to the Account tab. Every visitor of a
// demo instance shares this account, so they stay what the demo seeded.
if (body.username !== undefined || body.email !== undefined) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
}
const result = await this.profile.updateSettings(user.id, body);
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
// The address itself stays out of the row, like the key values above; the row
@@ -228,6 +233,7 @@ export class AuthController {
@Delete('avatar')
async deleteAvatar(@CurrentUser() user: User) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
return this.profile.deleteAvatar(user.id);
}
@@ -312,6 +318,7 @@ export class AuthController {
@Post('mcp-tokens')
@HttpCode(201)
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
await this.limit('login', req, 5);
const result = await this.tokens.createMcpToken(user.id, body.name);
return { token: result.token };
@@ -319,6 +326,7 @@ export class AuthController {
@Delete('mcp-tokens/:id')
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
await this.tokens.deleteMcpToken(user.id, id);
return { success: true };
}
@@ -343,6 +351,7 @@ export class AuthController {
@Post('api-tokens')
@HttpCode(201)
async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.apiKeys);
await this.limit('login', req, 5);
// No `scopes` means the key reads everything, which is what every key minted
// before this field existed does. Narrowing stays opt-in so the change
@@ -5,6 +5,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { MfaExempt } from '../auth-core/mfa-policy.guard';
import { setAuthCookie } from '../common/cookie';
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
import { RateLimitService } from '../common/rate-limit.service';
import { sessionClientFrom } from '../sessions/sessions.service';
import {
@@ -69,6 +70,9 @@ export class PasskeyController {
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) {
// Every visitor shares the demo account: a passkey enrolled on it would be
// one stranger's key to everybody's demo.
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
await this.limit('mfa', req, 5);
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
// — the RP ID itself is never derived from request headers.
@@ -81,6 +85,7 @@ export class PasskeyController {
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
return { success: true, credential: result.credential };
@@ -142,6 +147,7 @@ export class PasskeyController {
@Patch('credentials/:id')
@UseGuards(JwtAuthGuard)
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
await this.passkeys.renamePasskey(user.id, id, body?.name);
return { success: true };
}
@@ -154,6 +160,7 @@ export class PasskeyController {
@Body() body: PasskeyDeleteDto,
@Req() req: Request,
) {
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
await this.limit('login', req, 5);
await this.passkeys.deletePasskey(user.id, id, body?.password);
await this.audit.writeAudit({
@@ -1,6 +1,8 @@
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { DEMO_WRITE_ERROR, isDemoWriteBlocked } from '../common/demo-write';
import { ResponseContract } from '../common/response-contract';
import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor';
import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants';
@@ -130,6 +132,8 @@ export class CollabController {
constructor(
private readonly collab: CollabService,
private readonly storage: StorageService,
// Whether DEMO_MODE is on, read on every upload: the demo account's are refused.
private readonly env: RuntimeEnvService,
) {}
private async requireTrip(tripId: string, user: User) {
@@ -242,6 +246,12 @@ export class CollabController {
};
try {
const trip = await this.requireTrip(tripId, user);
// After the 404, as on the trip-file upload, so the demo account cannot
// learn which trips exist; refused like every other upload of it
// (common/demo-write.ts).
if (isDemoWriteBlocked(this.env, user.email)) {
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (!(await this.collab.canUploadFiles(trip, user))) {
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
@@ -491,6 +501,11 @@ export class CollabController {
cleanupSpool();
throw err;
}
// Only the images are refused to the demo account; its text still posts.
if (uploaded.length && isDemoWriteBlocked(this.env, user.email)) {
cleanupSpool();
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) {
cleanupSpool();
throw new HttpException({ error: 'No permission to upload files' }, 403);
+3
View File
@@ -7,6 +7,7 @@ import { CollabPolls } from '../../db/entities/CollabPolls.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { PluginGuardsModule } from '../../nest-rpc/plugin-guards.module';
import { AddonsModule } from '../addons/addons.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { RateLimitModule } from '../common/rate-limit.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -57,6 +58,8 @@ import { MulterModule } from '@nestjs/platform-express';
PluginGuardsModule,
AddonsModule,
RateLimitModule,
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
AppConfigModule,
],
controllers: [CollabController],
providers: [CollabService, CollabMcp, CollabRpc],
+53 -4
View File
@@ -1,7 +1,9 @@
import { readEnv } from '../../app-config';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { isDemoEmail } from './demo';
import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo';
import { DomainError } from './domain-error';
/** The exact 403 body the six upload endpoints have always returned. */
/** The exact 403 body the upload endpoints answer the demo account with. */
export const DEMO_WRITE_ERROR = {
error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.',
} as const;
@@ -10,9 +12,11 @@ export const DEMO_WRITE_ERROR = {
* Whether this user's writes are blocked because the instance runs in demo mode.
*
* The condition was copy-pasted into six upload handlers, each with its own copy
* of the message. It lives here once now, and every one of them calls it.
* of the message. It lives here once now, and every one of them calls it, as do
* the journey photo, video and cover uploads and the collab note file and chat
* image uploads that came without it.
*
* Deliberately a function and not a guard, though all six sites are handlers and
* Deliberately a function and not a guard, though every site is a handler and
* a guard is the obvious shape. Guards run before the multipart parser, so
* throwing from one leaves the request body unread and Node resets the
* connection: the client sees ECONNRESET instead of the 403. PROFILE-015 catches
@@ -29,3 +33,48 @@ export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null
// `this.demo.isDemoUserId(userId)` instead: the same check, resolving `env`/
// the repository via its own constructor rather than taking them as
// parameters.
/**
* The shared account every visitor of a demo instance signs in as. The same
* reading of the environment as the password, MFA and session refusals in
* `auth/`, so a controller that has no RuntimeEnvService of its own (passkeys,
* OAuth) asks the same question without a new constructor parameter.
*/
export function isDemoAccount(user: { email?: string | null } | null | undefined): boolean {
return readEnv().demo.enabled && isDemoEmail(user?.email);
}
/**
* What the demo account is told when it tries to change something every other
* visitor shares. One sentence per area, so the client can show it as it is.
*/
export const DEMO_ACCOUNT_REFUSAL = {
apiKeys: 'API keys cannot be created in demo mode.',
mcp: 'MCP clients cannot be connected in demo mode.',
account: 'The demo account cannot be changed in demo mode.',
passkeys: 'Passkeys cannot be changed in demo mode.',
} as const;
/** Refuses the write with a 403 for the demo account; does nothing for anybody else. */
export function refuseForDemoAccount(user: { email?: string | null } | null | undefined, message: string): void {
if (isDemoAccount(user)) throw new DomainError(403, message);
}
/**
* Whether a settings write of this key is one the demo account does not get to
* make (`DEMO_ACCOUNT_SETTINGS`). The route answers as if it saved nothing,
* rather than refusing: a client that sends the language along with other keys
* keeps the rest of its save.
*/
export function isDemoLockedSetting(user: { email?: string | null } | null | undefined, key: string): boolean {
return Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key) && isDemoAccount(user);
}
/** The settings of a bulk save without the keys the demo account keeps fixed. */
export function withoutDemoLockedSettings(
user: { email?: string | null } | null | undefined,
settings: Record<string, unknown>,
): Record<string, unknown> {
if (!isDemoAccount(user)) return settings;
return Object.fromEntries(Object.entries(settings).filter(([key]) => !Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key)));
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { Injectable } from '@nestjs/common';
* hazard, confirmed by `task-0-report.md` §6), so an injected `EntityManager`
* resolving the current request's fork is exactly the right shape.
*
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the six-upload-endpoint sibling
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the upload endpoints' sibling
* check) stay exactly where they are, in `common/demo-write.ts` — a
* different, pure-function check over an already-known email string that
* this task does not touch.
+12 -1
View File
@@ -1,3 +1,5 @@
import { DEMO_ACCOUNT_EMAILS } from '@trek/shared';
// Central registry of demo-user email addresses.
//
// Historical: the demo account was seeded as "demo@trek.app" (see
@@ -23,8 +25,17 @@ export const DEMO_PASS = 'demo12345';
* Includes the historical `demo@nomad.app` identifier so instances that
* upgraded in place without resetting the DB still hit demo-mode guards.
*/
export const DEMO_EMAILS: ReadonlySet<string> = new Set([DEMO_EMAIL_PRIMARY, 'demo@nomad.app']);
export const DEMO_EMAILS: ReadonlySet<string> = new Set(DEMO_ACCOUNT_EMAILS);
export function isDemoEmail(email: string | null | undefined): boolean {
return !!email && DEMO_EMAILS.has(email);
}
/**
* User settings the shared demo account always holds, whatever a visitor picks.
* The demo seed writes them on every boot and after every hourly reset, and the
* settings routes leave them alone for the demo account (`demo-write.ts`), so a
* visitor cannot switch the demo every other visitor sees into another
* language.
*/
export const DEMO_ACCOUNT_SETTINGS: Readonly<Record<string, string>> = { language: 'en' };
@@ -22,6 +22,8 @@ import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
@@ -172,6 +174,8 @@ export class JourneyController {
private readonly photoCapture: JourneyPhotoCaptureService,
// Which entries the gallery's photos sit on, told to every tab once written.
private readonly photoLinks: JourneyPhotoLinksService,
// Whether DEMO_MODE is on, read on every upload (refuseDemoUpload).
private readonly env: RuntimeEnvService,
) {}
/**
@@ -199,6 +203,7 @@ export class JourneyController {
* The commit has to happen first (the Immich mirror reads the final path), so
* every refusal after it has to take the bytes back out. Nothing sweeps
* orphans, and a loop of rejected POSTs would otherwise just fill the disk.
* A demo instance keeps them all the same (StorageService.delete).
*/
private async discardJourneyUploads(files: Express.Multer.File[]): Promise<void> {
for (const f of files) {
@@ -207,6 +212,22 @@ export class JourneyController {
}
}
/**
* Refuse the shared demo account's upload in demo mode, with the 403 the
* other upload routes answer (common/demo-write.ts). Called in the handler,
* once multer has drained the body, so the 403 arrives instead of an
* ECONNRESET. It runs before the commit: the spooled parts are all there is
* to take back, and a committed object would stay, since a demo instance
* keeps what is deleted (StorageService.delete).
*/
private refuseDemoUpload(user: User, files: ReadonlyArray<Express.Multer.File | undefined>): void {
if (!isDemoWriteBlocked(this.env, user.email)) return;
for (const f of files) {
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
}
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
@ResponseContract(journeyListResponseSchema)
@@ -270,6 +291,7 @@ export class JourneyController {
@Post('entries/:entryId/photos')
@UseInterceptors(FilesInterceptor('photos'))
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) {
this.refuseDemoUpload(user, files ?? []);
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
@@ -336,6 +358,7 @@ export class JourneyController {
) {
const video = files?.video?.[0];
const poster = files?.poster?.[0];
this.refuseDemoUpload(user, [video, poster]);
const cleanup = () => {
for (const f of [video, poster]) {
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
@@ -477,6 +500,7 @@ export class JourneyController {
@Post(':id/gallery/photos')
@UseInterceptors(FilesInterceptor('photos'))
async uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
this.refuseDemoUpload(user, files ?? []);
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
@@ -509,6 +533,7 @@ export class JourneyController {
) {
const video = files?.video?.[0];
const poster = files?.poster?.[0];
this.refuseDemoUpload(user, [video, poster]);
// multer already spooled both parts; clean them up on a pre-commit rejection
// so a bad POST doesn't orphan a 500 MB clip (#823).
const cleanup = () => {
@@ -579,6 +604,7 @@ export class JourneyController {
@HttpCode(200) // Express answers cover with res.json (200).
@UseInterceptors(FileInterceptor('cover'))
async cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
this.refuseDemoUpload(user, [file]);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
@@ -1,5 +1,6 @@
import { Users } from '../../db/entities/Users.entity';
import { AddonsModule } from '../addons/addons.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { UserConnectionRepositoriesModule } from '../database/user-connection-repositories.module';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
@@ -42,6 +43,8 @@ import { MulterModule } from '@nestjs/platform-express';
}),
StorageModule,
AddonsModule,
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
AppConfigModule,
MemoriesModule,
JourneyDomainModule,
JourneyPhotoCaptureModule,
@@ -106,6 +106,8 @@ export class ThumbnailService {
let removed = 0;
for await (const stat of this.storage.list('journey', 'thumbs/')) {
if (!stat.key.endsWith('.jpg') || live.has(stat.key)) continue;
// A demo instance keeps it (StorageService.keepsOnDelete): not reclaimed, so not counted.
if (this.storage.keepsOnDelete('journey', stat.key)) continue;
try {
await this.storage.delete('journey', stat.key);
removed++;
@@ -4,6 +4,7 @@ import { CookieAuthGuard } from '../auth-core/cookie-auth.guard';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { OptionalJwtGuard } from '../auth-core/optional-jwt.guard';
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
import { RateLimitService } from '../common/rate-limit.service';
import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto';
import { OauthService } from './oauth.service';
@@ -122,6 +123,8 @@ export class OauthApiController {
if (body.state) url.searchParams.set('state', body.state);
return { redirect: url.toString() };
}
// Saying no stays possible. Handing the shared demo account to an app does not.
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
const scopes = validation.scopes!;
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
const code = await this.oauth.createAuthCode({
@@ -157,6 +160,7 @@ export class OauthApiController {
@UseGuards(CookieAuthGuard)
async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
await this.requireMcp403();
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
const result = await this.oauth.createOAuthClient(
user.id,
body.name,
@@ -176,6 +180,7 @@ export class OauthApiController {
@UseGuards(CookieAuthGuard)
async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
@@ -187,6 +192,7 @@ export class OauthApiController {
@UseGuards(CookieAuthGuard)
async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
@@ -205,6 +211,7 @@ export class OauthApiController {
@UseGuards(CookieAuthGuard)
async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
await this.requireMcp403();
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
@@ -5,6 +5,7 @@ import { getClientIp } from '../audit/client-ip';
import { AdminGuard } from '../auth-core/admin.guard';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { isDemoLockedSetting, withoutDemoLockedSettings } from '../common/demo-write';
import { isManagedLockedKey, splitManagedKeys } from '../common/managed';
import { ResponseContract } from '../common/response-contract';
import { SettingsWritesService } from './settings-writes.service';
@@ -99,6 +100,10 @@ export class SettingsController {
if (isManagedLockedKey(body.key) && this.env.isManaged()) {
return { success: true, key: body.key, managed: true };
}
// The demo account keeps its language: every visitor sees the same demo.
if (isDemoLockedSetting(user, body.key)) {
return { success: true, key: body.key, unchanged: true };
}
// The client echoes a redacted secret back unchanged — treat as a no-op.
if (body.value === MASKED_SETTING_VALUE) {
return { success: true, key: body.key, unchanged: true };
@@ -113,7 +118,7 @@ export class SettingsController {
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto, @Headers('x-socket-id') socketId?: string) {
this.assertMayWriteInstanceEndpoint(body.settings);
this.assertValidValues(body.settings);
const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged());
const { allowed, blocked } = splitManagedKeys(withoutDemoLockedSettings(user, body.settings), this.env.isManaged());
try {
const updated = await this.writes.saveMany(user.id, allowed, socketId);
return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) };
+73 -1
View File
@@ -1,3 +1,5 @@
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { logDebug } from '../audit/audit-log.logger';
import { contentTypeFor } from './content-type';
import type { ReplicaFailure } from './drivers/mirror.driver';
import { decideRange, isNotModified, validatorsFor, type ServingHeaders, type Validators } from './http-serving';
@@ -33,6 +35,47 @@ interface Resolved {
key: string;
}
/**
* The folder under `files` the assistant keeps the files attached to a
* conversation in (assistant-attachments.service.ts takes it from here). They
* live by a retention of days, so no demo baseline can rely on their bytes, and
* a demo instance deletes them as any other does: attaching is not refused to
* the demo account, and kept bytes of a public visitor's files would stay for good.
*/
export const ASSISTANT_FILES_PREFIX = 'assistant/';
/**
* What a delete does on a demo instance, per category: `true` leaves the
* object where it is. The hourly demo reset (src/demo/demo-reset.ts) puts only
* the database back to its baseline, so a file a visitor deleted in between
* would be missing from the restored rows for good. The categories holding
* content that rows point at are kept; the caches and the backups are deleted
* as on any other instance. A Record, so a new category does not compile until
* it is decided here.
*/
const KEPT_ON_DEMO: Readonly<Record<ServedCategory, boolean>> = {
// Trip files, collab note files and chat images. The assistant's attachments
// under ASSISTANT_FILES_PREFIX are the exception, see keepsOnDelete.
files: true,
// Journey photos, videos, posters, covers and thumbs. A thumb a row records
// is served as it is and never made again, so it stays with its photo.
journey: true,
// Trip and collection covers.
covers: true,
avatars: true,
// Images uploaded for places.
places: true,
// The legacy shared photo folder, served and backed up like the uploads.
photos: true,
// A cache of the photos the map providers hand back, written by every lookup
// and swept nightly. Kept, it would grow without bound on a public demo.
'photos-google': false,
// A cache of Immich and Synology assets that lives an hour and is fetched again on a miss.
'photos-trek': false,
// Never part of the baseline: the admin deletes them and auto-backup rotates them.
backups: false,
};
/**
* The facade all call sites use — and the ONLY entry point: callers address
* storage exclusively as (category, name) on this injected service. They
@@ -47,7 +90,12 @@ interface Resolved {
*/
@Injectable()
export class StorageService {
constructor(private readonly registry: StorageRegistryService) {}
constructor(
private readonly registry: StorageRegistryService,
// Whether DEMO_MODE is on, read on every delete. A hand-built facade (the
// unit suites) reads the same live env the container's provider does.
private readonly runtimeEnv: RuntimeEnvService = new RuntimeEnvService(),
) {}
/** Driver-agnostic global scratch space (data/tmp). */
tempDir(): string {
@@ -90,8 +138,32 @@ export class StorageService {
return (await this.stat(category, name)) !== null;
}
/**
* Whether delete() leaves this object where it is: on a demo instance, one of
* a category KEPT_ON_DEMO keeps, the assistant's attachments excepted. A
* sweep asks first, so it never counts as reclaimed an object that stays.
*/
keepsOnDelete(category: ServedCategory, name: string): boolean {
if (!KEPT_ON_DEMO[category]) return false;
if (category === 'files' && name.startsWith(ASSISTANT_FILES_PREFIX)) return false;
return this.runtimeEnv.isDemoMode();
}
/**
* Idempotent, like the drivers. On a demo instance an object keepsOnDelete()
* names is left alone and the call resolves as if it were gone; a later put()
* under the same name still replaces it. That holds for the bytes a request
* takes back out after a refused upload too: on a demo they stay, as a
* visitor's uploads already do once the hourly reset drops their rows. The
* name is validated first in both modes, so a hostile one is refused either way.
*/
async delete(category: ServedCategory, name: string): Promise<void> {
const { driver, key } = this.resolve(category, name);
if (this.keepsOnDelete(category, name)) {
// The category only: the name can come from a request, and a log line is no place for it.
logDebug(`[storage] demo mode keeps a deleted ${category} object, the hourly reset restores rows that name it`);
return;
}
return driver.delete(key);
}
+48 -1
View File
@@ -32,8 +32,9 @@ import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import path from 'node:path';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
@@ -329,4 +330,50 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
);
});
});
describe('demo mode', () => {
// Over the wire, because a refusal from a guard would reset the connection
// while the client is still sending (PROFILE-015): the 403 has to arrive with
// its body, and nothing of the upload may be written.
const refused = { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' };
beforeAll(async () => {
// Pinned id: sessionCookie(2) signs for exactly this user, the owner of trip 6.
await makeUser(orm, { id: 2, username: 'demo', email: 'demo@trek.app', role: 'user', password_version: 0 });
await insertRow(orm, Trips, { id: 6, title: 'Demo trip', user: 2 });
});
beforeEach(() => {
vi.stubEnv('DEMO_MODE', 'true');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('403 with the demo body for a note file, and no file row', async () => {
const res = await request(server)
.post('/api/trips/6/collab/notes/1/files')
.set('Cookie', sessionCookie(2))
.attach('file', path.join(__dirname, '../fixtures/test.pdf'));
expect(res.status).toBe(403);
expect(res.body).toEqual(refused);
expect(await countRows(orm, TripFiles)).toBe(0);
});
it('403 with the demo body for a chat image, and no message row; a text message still posts', async () => {
const res = await request(server)
.post('/api/trips/6/collab/messages')
.set('Cookie', sessionCookie(2))
.field('text', 'look')
.attach('images', path.join(__dirname, '../fixtures/small-image.jpg'));
expect(res.status).toBe(403);
expect(res.body).toEqual(refused);
expect(await countRows(orm, CollabMessages)).toBe(0);
const text = await request(server)
.post('/api/trips/6/collab/messages')
.set('Cookie', sessionCookie(2))
.send({ text: 'hi' });
expect(text.status).toBe(201);
expect(text.body.message).toMatchObject({ text: 'hi', trip_id: 6, user_id: 2 });
});
});
});
@@ -0,0 +1,198 @@
/**
* Demo account locks e2e: on a demo instance the shared demo account cannot
* mint API keys or MCP tokens, change its name, address or picture, or touch
* passkeys, and its language stays English. Every visitor signs in as that one
* account, so each of these would be one stranger's change to everybody's demo.
*
* Boots AuthModule (with the passkey routes) and SettingsModule with the real
* JwtAuthGuard, the Zod pipe and the exception filter over a migrated temp
* SQLite db. The other accounts on the same instance, and the demo address on
* an instance that is not a demo, keep every one of these routes.
*/
import { Settings } from '../../src/db/entities/Settings.entity';
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { DEMO_ACCOUNT_REFUSAL } from '../../src/nest/common/demo-write';
import { DemoModule } from '../../src/nest/common/demo.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { SettingsModule } from '../../src/nest/settings/settings.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { deleteRows, findRow, insertRow } from '../helpers/factories/rows';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { resetRateLimits } from '../helpers/test-db';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const db = createSnapshotTestDb();
const DEMO = 1;
const VISITOR_ADMIN = 2;
let orm: TestOrm;
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
AppConfigModule,
RealtimeModule,
DemoModule,
AuthModule,
SettingsModule,
],
})
.overrideProvider(RealtimeService)
.useValue(new FakeRealtimeService())
.overrideProvider(MailerService)
.useValue({ sendPasswordResetEmail: vi.fn() })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
async function languageOf(userId: number): Promise<string | undefined> {
return (await findRow(orm, Settings, { user: userId, key: 'language' }))?.value;
}
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: DEMO, username: 'demo', email: 'demo@trek.app' });
await makeUser(orm, { id: VISITOR_ADMIN, username: 'admin', email: 'admin@example.test', role: 'admin' });
app = await build();
server = app.getHttpServer();
});
beforeEach(async () => {
vi.stubEnv('DEMO_MODE', 'true');
await resetRateLimits(app);
await deleteRows(orm, Settings);
await insertRow(orm, Settings, { user: DEMO, key: 'language', value: 'en' });
});
afterEach(() => {
vi.unstubAllEnvs();
});
afterAll(async () => {
await app.close();
await orm.close();
});
describe('Demo account locks e2e', () => {
it('DEMOLOCK-001: the demo account cannot create an API key', async () => {
const res = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.apiKeys });
});
it('DEMOLOCK-002: the demo account can neither create nor delete an MCP token', async () => {
const created = await request(server).post('/api/auth/mcp-tokens').set(as(DEMO)).send({ name: 'Claude' });
expect(created.status).toBe(403);
expect(created.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp });
const removed = await request(server).delete('/api/auth/mcp-tokens/1').set(as(DEMO));
expect(removed.status).toBe(403);
expect(removed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp });
});
it('DEMOLOCK-003: the name, the address and the picture of the demo account stay as seeded', async () => {
const name = await request(server).put('/api/auth/me/settings').set(as(DEMO)).send({ username: 'renamed' });
expect(name.status).toBe(403);
expect(name.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account });
const address = await request(server)
.put('/api/auth/me/settings')
.set(as(DEMO))
.send({ email: 'mine@example.test' });
expect(address.status).toBe(403);
const avatar = await request(server).delete('/api/auth/avatar').set(as(DEMO));
expect(avatar.status).toBe(403);
expect(avatar.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account });
});
it('DEMOLOCK-004: passkeys of the demo account can be neither renamed nor deleted', async () => {
const renamed = await request(server)
.patch('/api/auth/passkey/credentials/abc')
.set(as(DEMO))
.send({ name: 'Laptop' });
expect(renamed.status).toBe(403);
expect(renamed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys });
const deleted = await request(server)
.delete('/api/auth/passkey/credentials/abc')
.set(as(DEMO))
.send({ password: 'demo12345' });
expect(deleted.status).toBe(403);
expect(deleted.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys });
});
it('DEMOLOCK-005: a language picked on the demo account is answered as unchanged and not stored', async () => {
const one = await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' });
expect(one.status).toBe(200);
expect(one.body).toEqual({ success: true, key: 'language', unchanged: true });
expect(await languageOf(DEMO)).toBe('en');
});
it('DEMOLOCK-006: a bulk save of the demo account keeps every other key and drops the language', async () => {
const res = await request(server)
.post('/api/settings/bulk')
.set(as(DEMO))
.send({ settings: { language: 'fr', temperature_unit: 'fahrenheit' } });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, updated: 1 });
expect(await languageOf(DEMO)).toBe('en');
const settings = await request(server).get('/api/settings').set(as(DEMO));
expect(settings.body.settings).toEqual(expect.objectContaining({ language: 'en', temperature_unit: 'fahrenheit' }));
});
it('DEMOLOCK-007: the admin of a demo instance keeps every one of these routes', async () => {
const token = await request(server).post('/api/auth/api-tokens').set(as(VISITOR_ADMIN)).send({ name: 'Admin' });
expect(token.status).toBe(201);
const language = await request(server)
.put('/api/settings')
.set(as(VISITOR_ADMIN))
.send({ key: 'language', value: 'de' });
expect(language.status).toBe(200);
expect(await languageOf(VISITOR_ADMIN)).toBe('de');
});
it('DEMOLOCK-008: off a demo instance the demo address is an account like any other', async () => {
vi.stubEnv('DEMO_MODE', 'false');
const token = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' });
expect(token.status).toBe(201);
await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' });
expect(await languageOf(DEMO)).toBe('de');
});
});

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