Compare commits

...

71 Commits

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

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

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

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

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

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

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

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

The lockfile moves maplibre-gl and its own dependencies only; the
Leaflet bridge already allows ^6.0.0 as its peer.
2026-10-24 22:41:56 +00:00
Maurice f375a2f3cc fix(collab): a quoted message wraps to two lines instead of pushing its bubble out of the chat
The quote inside a reply sat on one line with an ellipsis, and that unbroken
line set the bubble's minimum width, so a long quote pushed the bubble past
the edge of the chat and cut its words off. It wraps now and is cut after two
lines, on desktop and on the phone.
2026-10-25 00:21:06 +02:00
298 changed files with 6972 additions and 1559 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 -1
View File
@@ -6,7 +6,7 @@
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/PDF/TripPDF.tsx": 1082,
+1 -2
View File
@@ -51,7 +51,6 @@
"components/Journey/journeyMapPopup.ts": 17,
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
@@ -207,7 +206,7 @@
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 21,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
"mobile/screens/settings/MSettingsMap.tsx": 5,
"mobile/screens/settings/MSettingsMcp.tsx": 30,
@@ -140,7 +140,17 @@ export function ChatBubble({
>
{reply.name}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{/* Wrapped and cut after two lines: a quote kept on one line set the bubble's width and pushed it out of the chat. */}
<div
style={{
opacity: 0.8,
overflow: 'hidden',
whiteSpace: 'normal',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{reply.text}
</div>
</div>
@@ -26,6 +26,8 @@ describe('the Collab pieces', () => {
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
expect(screen.getByText('Anna')).toBeInTheDocument();
expect(screen.getByText('Train?')).toBeInTheDocument();
// A long quote wraps instead of setting the bubble's width (it pushed the bubble out of the chat).
expect(screen.getByText('Train?').getAttribute('style')).toContain('white-space: normal');
});
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import {
PHOTO_CREDITS,
PUBLIC_DOMAIN,
commonsPage,
creditTitle,
licenseDeed,
type PhotoCredit,
} from './demoCreditsModel';
type Translate = ReturnType<typeof useTranslation>['t'];
const LINK = 'underline decoration-edge underline-offset-2 hover:text-content';
/** One photo: its title linking to its page on Commons, who took it, and its license linking to the deed. */
function CreditRow({ credit, t }: { credit: PhotoCredit; t: Translate }) {
const deed = licenseDeed(credit.license);
const license = credit.license === PUBLIC_DOMAIN ? t('demo.credits.publicDomain') : credit.license;
return (
<li className="flex flex-col gap-0.5 py-2.5">
<a
href={commonsPage(credit.file)}
target="_blank"
rel="noopener noreferrer"
className={`${LINK} self-start break-words font-medium text-content`}
style={fs(12.5, 'body')}
>
{creditTitle(credit.file)}
</a>
<span className="flex flex-wrap gap-x-3 gap-y-0.5 text-content-muted" style={fs(12, 'body')}>
<span>{t('demo.credits.by', { author: credit.author })}</span>
{deed ? (
<a href={deed} target="_blank" rel="noopener noreferrer" className={LINK}>
{license}
</a>
) : (
<span>{license}</span>
)}
</span>
</li>
);
}
/**
* The credits for the demo's photos, the welcome dialog's second view. Its own
* chunk, loaded when a visitor opens it: only a demo instance shows the
* welcome, and few visitors open its credits.
*/
export default function DemoCredits() {
const { t } = useTranslation();
const intro = useRef<HTMLParagraphElement>(null);
// The dialog's body keeps the welcome's scroll position when the credits
// take its place; they start at their top.
useEffect(() => {
intro.current?.scrollIntoView({ block: 'nearest' });
}, []);
return (
<>
<p ref={intro} className="m-0 scroll-mt-5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
{t('demo.credits.intro')}
</p>
<ul
aria-label={t('demo.credits.title')}
className="m-0 list-none divide-y divide-edge-faint rounded-[16px] border border-edge-faint bg-surface-card px-3.5"
>
{PHOTO_CREDITS.map((credit) => (
<CreditRow key={credit.file} credit={credit} t={t} />
))}
</ul>
</>
);
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import DemoLock from './DemoLock';
describe('DemoLock', () => {
it('FE-COMP-DEMOLOCK-001: unlocked, the section is the section and nothing more', () => {
const { container } = render(
<DemoLock locked={false} reason="Not here.">
<button type="button">Create key</button>
</DemoLock>
);
expect(screen.getByRole('button', { name: 'Create key' })).toBeEnabled();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(container.querySelector('[data-demo-lock]')).toBeNull();
});
it('FE-COMP-DEMOLOCK-002: locked, the section stays visible but out of reach, under the badge and the reason', () => {
const { container } = render(
<DemoLock locked reason="API keys cannot be created in the demo.">
<button type="button">Create key</button>
</DemoLock>
);
const note = screen.getByRole('note');
expect(note).toHaveTextContent('Demo mode');
expect(note).toHaveTextContent('API keys cannot be created in the demo.');
expect(screen.queryByRole('button', { name: 'Create key' })).not.toBeInTheDocument();
const veiled = container.querySelector('[data-demo-lock] > [inert]');
expect(veiled).toHaveAttribute('aria-hidden', 'true');
expect(veiled).toContainElement(screen.getByRole('button', { hidden: true, name: 'Create key' }));
expect(note.getAttribute('style')).toContain('repeating-linear-gradient');
expect(note.getAttribute('style')).toContain('var(--text-primary)');
});
it('FE-COMP-DEMOLOCK-003: a single row puts the note in its middle, a section at its top', () => {
const { rerender } = render(
<DemoLock locked reason="r" size="row">
<span>row</span>
</DemoLock>
);
expect(screen.getByRole('note').className).toContain('items-center');
rerender(
<DemoLock locked reason="r">
<span>section</span>
</DemoLock>
);
expect(screen.getByRole('note').className).toContain('items-start');
});
});
+57
View File
@@ -0,0 +1,57 @@
import { Lock } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
interface DemoLockProps {
/** From useDemoAccountLock: false renders the children untouched. */
locked: boolean;
/** One line saying what the demo does not allow here. */
reason: string;
children: ReactNode;
/** `row` for a single settings row: the note sits in the middle instead of at the top. */
size?: 'section' | 'row';
className?: string;
}
/** Thin diagonal stripes in the theme's own ink, so they read in light and dark alike. */
const STRIPES =
'repeating-linear-gradient(135deg, color-mix(in srgb, var(--text-primary) 8%, transparent) 0 9px, transparent 9px 18px)';
/**
* A section the shared demo account may look at but not change: dimmed,
* out of reach for pointer and keyboard, under a striped veil that carries a
* small "Demo mode" badge and the reason. Used on the desktop settings and on
* the phone screens alike.
*/
export default function DemoLock({ locked, reason, children, size = 'section', className = '' }: DemoLockProps) {
const { t } = useTranslation();
if (!locked) return <>{children}</>;
return (
<div className={`relative ${className}`} data-demo-lock="">
<div inert aria-hidden="true" className="pointer-events-none select-none opacity-40 saturate-50">
{children}
</div>
<div
role="note"
className={`absolute inset-0 flex justify-center rounded-2xl border border-dashed border-edge-secondary ${
size === 'row' ? 'items-center p-1' : 'items-start p-3'
}`}
style={{ background: STRIPES }}
>
<div className="sticky top-3 flex max-w-full flex-wrap items-center justify-center gap-x-2.5 gap-y-1 rounded-2xl bg-surface-card px-3 py-2 text-center shadow-md ring-1 ring-edge-faint">
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-2 py-0.5 font-semibold uppercase tracking-[.06em] text-accent-text"
style={fs(10)}
>
<Lock size={11} strokeWidth={2.4} aria-hidden />
{t('demo.lock.badge')}
</span>
<span className="min-w-0 text-content-secondary" style={fs(12.5, 'body')}>
{reason}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import { act, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import DemoWelcome from './DemoWelcome';
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { serverTimezone: 'UTC' });
});
afterEach(() => {
vi.useRealTimers();
});
describe('DemoWelcome', () => {
it('FE-COMP-DEMOWELCOME-001: opens as a dialog saying what TREK is', () => {
render(<DemoWelcome />);
const dialog = screen.getByRole('dialog', { name: 'Live demo' });
expect(dialog).toHaveTextContent('Welcome to TREK');
expect(dialog).toHaveTextContent('The travel planner you share');
});
it('FE-COMP-DEMOWELCOME-002: says the demo resets every hour and what it cannot do', () => {
render(<DemoWelcome />);
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
for (const line of [
'Uploading photos, documents and covers',
'Changing the account, its password or 2FA',
'Creating API keys',
'Connecting MCP clients',
'Connecting your own AI model',
])
expect(screen.getByText(line)).toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-003: shows the highlights to try and what the full version adds', () => {
render(<DemoWelcome />);
for (const title of [
'Trip planner',
'Tours and hikes',
'Photo books in TREK Studio',
'Plan together live',
'Journey travel journal',
'Atlas world map',
'Vacay holiday planner',
'Collections',
'Works offline',
])
expect(screen.getByText(title)).toBeInTheDocument();
for (const line of ['Sign in with OIDC and SSO', 'Automatic backups', 'Plugins for new features'])
expect(screen.getByText(line)).toBeInTheDocument();
expect(screen.getByText('The AI assistant on your own model')).toBeInTheDocument();
expect(screen.getByText('An admin panel for users, addons and permissions')).toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-004: links to the repository in a new tab', () => {
render(<DemoWelcome />);
const link = screen.getByRole('link', { name: 'Source code on GitHub' });
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-COMP-DEMOWELCOME-005: the countdown runs live in the server zone', async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
// On a whole second, so every tick of the interval lands on one too.
vi.setSystemTime(new Date(Date.UTC(2026, 9, 24, 12, 17, 53)));
render(<DemoWelcome />);
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:07');
// Advancing the fake timers moves the fake clock with them: the fifth tick reads 12:17:58.
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:02');
});
it('FE-COMP-DEMOWELCOME-006: the button closes it', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Start exploring' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-007: Escape closes it too', () => {
render(<DemoWelcome />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-COMP-DEMOWELCOME-008: the photo credits open in its place and the back button returns', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
const list = await screen.findByRole('list', { name: 'Photo credits' }, { timeout: 5000 });
expect(within(list).getAllByRole('listitem')).toHaveLength(228);
expect(screen.getByText(/come from Wikimedia Commons/)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Photo credits' })).toBeInTheDocument();
expect(screen.queryByText('Everything resets every hour')).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Live demo' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back' })).toHaveFocus();
await user.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
expect(screen.queryByRole('list', { name: 'Photo credits' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Photo credits' })).toHaveFocus();
});
it('FE-COMP-DEMOWELCOME-009: a photo names its title, author and license, each linking to its source', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
const title = await screen.findByRole('link', { name: 'Lagoa Rasa (Sete Cidades) 2' }, { timeout: 5000 });
expect(title).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg');
expect(title).toHaveAttribute('target', '_blank');
expect(title).toHaveAttribute('rel', 'noopener noreferrer');
const row = within(title.closest('li') as HTMLElement);
expect(row.getByText('by Walmaul')).toBeInTheDocument();
const license = row.getByRole('link', { name: 'CC BY-SA 4.0' });
expect(license).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
expect(license).toHaveAttribute('target', '_blank');
expect(license).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-COMP-DEMOWELCOME-010: a public domain photo names it without a deed, and a cleaned author reads as a name', async () => {
const user = userEvent.setup();
render(<DemoWelcome />);
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
const abiskoTitle = await screen.findByRole('link', { name: 'AbiskoNationalpark01' }, { timeout: 5000 });
const abisko = within(abiskoTitle.closest('li') as HTMLElement);
expect(abisko.getByText('Public domain')).toBeInTheDocument();
expect(abisko.getAllByRole('link')).toHaveLength(1);
const zinnen = within(
screen.getByRole('link', { name: 'Drei Zinnen Tre Cime di Lavaredo Dolomites' }).closest('li') as HTMLElement
);
expect(zinnen.getByText('by Wolfgang Moroder')).toBeInTheDocument();
expect(zinnen.getByRole('link', { name: 'CC BY-SA 3.0' })).toHaveAttribute(
'href',
'https://creativecommons.org/licenses/by-sa/3.0/'
);
});
});
+205
View File
@@ -0,0 +1,205 @@
import { ArrowLeft, ArrowRight, Clock, ExternalLink, Images, RotateCcw } from 'lucide-react';
import { lazy, Suspense, useId } from 'react';
import { useTranslation } from '../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
PILL,
} from '../shared/DialogShell';
import ErrorBoundary from '../shared/ErrorBoundary';
import TrekIcon from '../shared/TrekIcon';
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, TREK_REPO_URL, type DemoLine } from './demoWelcomeModel';
import { useDemoWelcome } from './useDemoWelcome';
// The credits and their photo list load when a visitor opens them, not with the dashboard.
const DemoCredits = lazy(() => import('./DemoCredits'));
type Translate = ReturnType<typeof useTranslation>['t'];
const FOOTER_LINK = 'inline-flex items-center gap-1.5 font-medium text-content-muted no-underline hover:text-content';
/** The tiles: what a visitor can open right now, one card each. */
function FeatureGrid({ t }: { t: Translate }) {
return (
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{DEMO_FEATURES.map(({ id, icon: Icon, titleKey, bodyKey }) => (
<div key={id} className="flex gap-3 rounded-[16px] border border-edge-faint bg-surface-secondary p-3.5">
<span className="grid h-9 w-9 flex-none place-items-center rounded-[12px] bg-surface-card text-content shadow-sm">
<Icon size={17} strokeWidth={2} aria-hidden />
</span>
<div className="min-w-0">
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
{t(titleKey)}
</div>
<p className="m-0 mt-0.5 leading-snug text-content-muted" style={fs(12, 'body')}>
{t(bodyKey)}
</p>
</div>
</div>
))}
</div>
);
}
/** A short list with an icon per line, for what the demo leaves out and what the full version adds. */
function LineList({ lines, tone, t }: { lines: readonly DemoLine[]; tone: 'muted' | 'accent'; t: Translate }) {
const iconClass = tone === 'accent' ? 'text-success' : 'text-content-faint';
return (
<ul className="m-0 flex list-none flex-col gap-2 rounded-[16px] border border-edge-faint bg-surface-card p-3.5">
{lines.map(({ icon: Icon, labelKey }) => (
<li key={labelKey} className="flex items-center gap-2.5 text-content-secondary" style={fs(12.5, 'body')}>
<Icon size={15} strokeWidth={2} className={`flex-none ${iconClass}`} aria-hidden />
<span>{t(labelKey)}</span>
</li>
))}
</ul>
);
}
/** The welcome itself: the hourly reset, the tiles, and the two lists under them. */
function WelcomeBody({ t }: { t: Translate }) {
return (
<>
<div className="flex items-start gap-3 rounded-[16px] bg-warning-soft px-4 py-3">
<RotateCcw size={17} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" aria-hidden />
<div className="min-w-0">
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
{t('demo.welcome.resetTitle')}
</div>
<p className="m-0 mt-0.5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
{t('demo.welcome.resetBody')}
</p>
</div>
</div>
<DialogSection label={t('demo.welcome.tryTitle')}>
<FeatureGrid t={t} />
</DialogSection>
<div className="grid gap-5 md:grid-cols-2">
<DialogSection label={t('demo.welcome.limitsTitle')}>
<LineList lines={DEMO_LIMITS} tone="muted" t={t} />
</DialogSection>
<DialogSection label={t('demo.welcome.fullTitle')}>
<LineList lines={DEMO_FULL_VERSION} tone="accent" t={t} />
</DialogSection>
</div>
</>
);
}
/**
* The footer's links: the source code, and the credits for the demo's photos.
* One above the other on a phone, where the footer has no room for both
* beside the button.
*/
function FooterLinks({ t, onCredits, focusCredits }: { t: Translate; onCredits: () => void; focusCredits: boolean }) {
return (
<div className="flex min-w-0 flex-col items-start gap-1.5 sm:flex-row sm:items-center sm:gap-4">
<a
href={TREK_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={FOOTER_LINK}
style={fs(12.5, 'body')}
>
<ExternalLink size={15} strokeWidth={2} aria-hidden />
{t('demo.welcome.repo')}
</a>
<button
type="button"
onClick={onCredits}
autoFocus={focusCredits}
className={FOOTER_LINK}
style={fs(12.5, 'body')}
>
<Images size={15} strokeWidth={2} aria-hidden />
{t('demo.welcome.credits')}
</button>
</div>
);
}
/**
* The welcome a demo instance opens with: what TREK is, that the demo resets
* on the hour (with the live countdown), what to try right away, what the
* demo leaves out and what the full version adds. The photo credits open in
* its place and back. Closed by the button, Escape or a click on the
* backdrop, and shown again on the next visit.
*/
export default function DemoWelcome() {
const { t } = useTranslation();
const labelId = useId();
const { open, close, countdown, creditsOpen, backFromCredits, openCredits, closeCredits } = useDemoWelcome();
return (
<DialogShell
open={open}
onClose={close}
labelledBy={labelId}
width="wide"
header={
<DialogHeader
tile={
<DialogTile>
<TrekIcon className="h-7 w-7 text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={close}
eyebrow={t('demo.welcome.eyebrow')}
title={t(creditsOpen ? 'demo.credits.title' : 'demo.welcome.title')}
sub={creditsOpen ? undefined : t('demo.welcome.tagline')}
subWraps
pills={
<span className={PILL}>
<Clock size={13} strokeWidth={2.2} aria-hidden />
{t('demo.welcome.resetIn')}
<span className="font-geist tabular-nums" data-testid="demo-countdown">
{countdown}
</span>
</span>
}
/>
}
footer={
<DialogFooter>
{/* The start button keeps its place in both views, so switching does not move the focus onto it again. */}
{creditsOpen ? (
<DialogButton
onClick={closeCredits}
autoFocus
icon={<ArrowLeft size={15} strokeWidth={2.2} className="rtl:-scale-x-100" />}
>
{t('demo.credits.back')}
</DialogButton>
) : (
<FooterLinks t={t} onCredits={openCredits} focusCredits={backFromCredits} />
)}
<FooterSpacer />
<DialogButton variant="primary" onClick={close} autoFocus icon={<ArrowRight size={15} strokeWidth={2.2} />}>
{t('demo.welcome.start')}
</DialogButton>
</DialogFooter>
}
>
{creditsOpen ? (
<ErrorBoundary boundaryId="demo-credits">
<Suspense fallback={null}>
<DemoCredits />
</Suspense>
</ErrorBoundary>
) : (
<WelcomeBody t={t} />
)}
</DialogShell>
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { PHOTO_CREDITS, PUBLIC_DOMAIN, commonsPage, creditTitle, licenseDeed } from './demoCreditsModel';
describe('demo photo credits', () => {
it('FE-DEMO-CREDITS-001: credits every photo of the demo content, each file once', () => {
expect(PHOTO_CREDITS).toHaveLength(228);
expect(new Set(PHOTO_CREDITS.map((c) => c.file)).size).toBe(228);
for (const { file } of PHOTO_CREDITS) expect(file, file).toMatch(/\.jpe?g$/i);
});
it('FE-DEMO-CREDITS-002: every author is a plain name, with no Commons notice or link label left', () => {
for (const { file, author } of PHOTO_CREDITS) {
expect(author.trim(), file).not.toBe('');
expect(author, file).toBe(author.trim());
expect(author.length, `${file}: ${author}`).toBeLessThanOrEqual(60);
expect(author, file).not.toMatch(/\s{2,}|\n/);
expect(author, file).not.toMatch(
/Photo was taken|machine-readable|assumed|Feel free|copyright|https?:|User:|\((?:Website|Flickr)\)/i
);
}
const authorOf = (file: string) => PHOTO_CREDITS.find((c) => c.file === file)?.author;
expect(authorOf('Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg')).toBe('Wolfgang Moroder');
expect(authorOf('Iceland Godafoss 1972.jpg')).toBe('Roger McLassus');
expect(authorOf('Arashiyama Bamboo Grove (Unsplash).jpg')).toBe('Erol Ahmed');
});
it('FE-DEMO-CREDITS-003: every license has its deed, except the public domain', () => {
for (const { file, license } of PHOTO_CREDITS) {
if (license === PUBLIC_DOMAIN) expect(licenseDeed(license), file).toBeUndefined();
else expect(licenseDeed(license), `${file}: ${license}`).toMatch(/^https:\/\/creativecommons\.org\/.+\/$/);
}
});
it('FE-DEMO-CREDITS-004: derives the deed from the license name', () => {
expect(licenseDeed('CC BY-SA 4.0')).toBe('https://creativecommons.org/licenses/by-sa/4.0/');
expect(licenseDeed('CC BY 2.5')).toBe('https://creativecommons.org/licenses/by/2.5/');
expect(licenseDeed('CC BY-SA 2.0 de')).toBe('https://creativecommons.org/licenses/by-sa/2.0/de/');
expect(licenseDeed('CC0')).toBe('https://creativecommons.org/publicdomain/zero/1.0/');
expect(licenseDeed(PUBLIC_DOMAIN)).toBeUndefined();
expect(licenseDeed('GFDL')).toBeUndefined();
});
it('FE-DEMO-CREDITS-005: shows the title without its extension and links the page on Commons', () => {
expect(creditTitle('Café A Brasileira. Sculpture.jpg')).toBe('Café A Brasileira. Sculpture');
expect(creditTitle('Svínafellsjökull.JPG')).toBe('Svínafellsjökull');
expect(creditTitle('No extension')).toBe('No extension');
expect(commonsPage('Lagoa Rasa (Sete Cidades) 2.jpg')).toBe(
'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg'
);
expect(commonsPage('Glacier du Géant & Mont Blanc.jpg')).toBe(
'https://commons.wikimedia.org/wiki/File:Glacier_du_G%C3%A9ant_%26_Mont_Blanc.jpg'
);
});
});
@@ -0,0 +1,50 @@
import credits from './photoCredits.json';
/**
* One photo of the demo content as Wikimedia Commons credits it: the file's
* name there, its author and the short name of its license. The page on
* Commons, the title to show and the license's deed all follow from these, so
* photoCredits.json holds nothing else.
*/
export interface PhotoCredit {
file: string;
author: string;
license: string;
}
/**
* Every photo in the demo content, sorted by file name. The authors are the
* plain names: where Commons fills the field with a notice or a link label
* ("This Photo was taken by ...", "(Flickr)"), the list holds the name it
* gives, and where the author asks to be credited in a set form, that form.
*/
export const PHOTO_CREDITS: readonly PhotoCredit[] = credits;
/** The license name Commons gives a photo nobody holds a copyright in; it has no deed to link. */
export const PUBLIC_DOMAIN = 'Public domain';
const COMMONS_FILE_PAGE = 'https://commons.wikimedia.org/wiki/File:';
/** The file's name without its extension, the title Commons shows above the photo. */
export function creditTitle(file: string): string {
const dot = file.lastIndexOf('.');
return dot > 0 ? file.slice(0, dot) : file;
}
/** The file's page on Commons, with its full description and license. */
export function commonsPage(file: string): string {
return COMMONS_FILE_PAGE + encodeURIComponent(file.replace(/ /g, '_'));
}
/**
* The deed of a Creative Commons license by the short name Commons gives it:
* "CC BY-SA 4.0", a ported one such as "CC BY-SA 2.0 de", or "CC0". Nothing
* for the public domain, which is no license.
*/
export function licenseDeed(license: string): string | undefined {
if (license === 'CC0') return 'https://creativecommons.org/publicdomain/zero/1.0/';
const match = /^CC (BY(?:-SA)?) (\d\.\d)(?: ([a-z]{2}))?$/.exec(license);
if (!match) return undefined;
const [, terms, version, port] = match;
return `https://creativecommons.org/licenses/${terms.toLowerCase()}/${version}/${port ? `${port}/` : ''}`;
}
@@ -0,0 +1,59 @@
import en from '@trek/shared/i18n/en';
import { describe, expect, it } from 'vitest';
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, formatCountdown, secondsUntilReset } from './demoWelcomeModel';
describe('demo welcome model', () => {
it('FE-DEMO-MODEL-001: every tile and line names a key the English catalogue holds', () => {
const keys = [
...DEMO_FEATURES.flatMap((f) => [f.titleKey, f.bodyKey]),
...DEMO_LIMITS.map((l) => l.labelKey),
...DEMO_FULL_VERSION.map((l) => l.labelKey),
];
for (const key of keys) expect(Object.keys(en), key).toContain(key);
});
it('FE-DEMO-MODEL-002: shows the highlights a visitor can open, each once', () => {
expect(DEMO_FEATURES.map((f) => f.id)).toEqual([
'planner',
'tours',
'studio',
'collab',
'journey',
'atlas',
'vacay',
'collections',
'offline',
]);
});
it('FE-DEMO-MODEL-003: names everything the demo account cannot do', () => {
expect(DEMO_LIMITS.map((l) => l.labelKey)).toEqual([
'demo.limit.uploads',
'demo.limit.account',
'demo.limit.apiKeys',
'demo.limit.mcp',
'demo.limit.ai',
]);
});
it('FE-DEMO-MODEL-004: counts down to the next full hour in the server zone', () => {
const at = new Date(Date.UTC(2026, 9, 24, 12, 17, 53));
expect(secondsUntilReset(at, 'UTC')).toBe(42 * 60 + 7);
// A zone half an hour off UTC has its hour mark at half past, UTC time.
expect(secondsUntilReset(at, 'Asia/Kolkata')).toBe(12 * 60 + 7);
});
it('FE-DEMO-MODEL-005: falls back to the browser clock for a zone it does not know, or none', () => {
const at = new Date(2026, 9, 24, 12, 59, 30);
expect(secondsUntilReset(at, 'Not/AZone')).toBe(30);
expect(secondsUntilReset(at)).toBe(30);
expect(secondsUntilReset(new Date(2026, 9, 24, 13, 0, 0))).toBe(3600);
});
it('FE-DEMO-MODEL-006: formats the countdown as minutes and seconds', () => {
expect(formatCountdown(42 * 60 + 7)).toBe('42:07');
expect(formatCountdown(5)).toBe('00:05');
expect(formatCountdown(3600)).toBe('60:00');
expect(formatCountdown(-3)).toBe('00:00');
});
});
@@ -0,0 +1,111 @@
import type { LucideIcon } from 'lucide-react';
import {
BookImage,
Bookmark,
Bot,
CalendarDays,
DatabaseBackup,
Globe,
KeyRound,
LayoutDashboard,
Map as MapIcon,
Mountain,
NotebookPen,
Puzzle,
ShieldCheck,
Sparkles,
Terminal,
Upload,
UserLock,
Users,
WifiOff,
} from 'lucide-react';
/** One thing a visitor can try in the demo right away: a tile of the welcome dialog. */
interface DemoFeature {
id: string;
icon: LucideIcon;
titleKey: string;
bodyKey: string;
}
/** A line of the two lists under the tiles: what the demo leaves out, and what the full version adds. */
export interface DemoLine {
icon: LucideIcon;
labelKey: string;
}
export const DEMO_FEATURES: readonly DemoFeature[] = [
{ id: 'planner', icon: MapIcon, titleKey: 'demo.feature.planner.title', bodyKey: 'demo.feature.planner.body' },
{ id: 'tours', icon: Mountain, titleKey: 'demo.feature.tours.title', bodyKey: 'demo.feature.tours.body' },
{ id: 'studio', icon: BookImage, titleKey: 'demo.feature.studio.title', bodyKey: 'demo.feature.studio.body' },
{ id: 'collab', icon: Users, titleKey: 'demo.feature.collab.title', bodyKey: 'demo.feature.collab.body' },
{ id: 'journey', icon: NotebookPen, titleKey: 'demo.feature.journey.title', bodyKey: 'demo.feature.journey.body' },
{ id: 'atlas', icon: Globe, titleKey: 'demo.feature.atlas.title', bodyKey: 'demo.feature.atlas.body' },
{ id: 'vacay', icon: CalendarDays, titleKey: 'demo.feature.vacay.title', bodyKey: 'demo.feature.vacay.body' },
{
id: 'collections',
icon: Bookmark,
titleKey: 'demo.feature.collections.title',
bodyKey: 'demo.feature.collections.body',
},
{ id: 'offline', icon: WifiOff, titleKey: 'demo.feature.offline.title', bodyKey: 'demo.feature.offline.body' },
];
/** What the shared demo account cannot do; the server refuses each of them too. */
export const DEMO_LIMITS: readonly DemoLine[] = [
{ icon: Upload, labelKey: 'demo.limit.uploads' },
{ icon: UserLock, labelKey: 'demo.limit.account' },
{ icon: KeyRound, labelKey: 'demo.limit.apiKeys' },
{ icon: Terminal, labelKey: 'demo.limit.mcp' },
{ icon: Sparkles, labelKey: 'demo.limit.ai' },
];
export const DEMO_FULL_VERSION: readonly DemoLine[] = [
{ icon: ShieldCheck, labelKey: 'demo.full.sso' },
{ icon: DatabaseBackup, labelKey: 'demo.full.backups' },
{ icon: Puzzle, labelKey: 'demo.full.plugins' },
{ icon: Bot, labelKey: 'demo.full.assistant' },
{ icon: LayoutDashboard, labelKey: 'demo.full.admin' },
];
export const TREK_REPO_URL = 'https://github.com/liketrek/TREK';
/** The demo resets on the hour (the server's `0 * * * *` cron). */
const HOUR_SECONDS = 3600;
/**
* Minute and second of the hour at `now` in the server's time zone, where the
* reset cron runs. A zone whose offset is not whole hours (India, Nepal) moves
* the hour mark; an unknown zone falls back to the browser's clock.
*/
function minuteAndSecond(now: Date, timeZone: string | undefined): [number, number] {
if (timeZone) {
try {
const parts = new Intl.DateTimeFormat('en-US', { timeZone, minute: 'numeric', second: 'numeric' }).formatToParts(
now
);
const read = (type: 'minute' | 'second') => Number(parts.find((p) => p.type === type)?.value);
const minute = read('minute');
const second = read('second');
if (Number.isFinite(minute) && Number.isFinite(second)) return [minute, second];
} catch {
// A zone this browser does not know: the local clock below.
}
}
return [now.getMinutes(), now.getSeconds()];
}
/** Seconds until the next hourly reset, from 1 up to 3600. */
export function secondsUntilReset(now: Date, timeZone?: string): number {
const [minute, second] = minuteAndSecond(now, timeZone);
return HOUR_SECONDS - (minute * 60 + second);
}
/** The countdown as minutes and seconds, `42:07`. */
export function formatCountdown(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safe / 60);
const rest = safe % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
@@ -0,0 +1,498 @@
[
{
"file": "(Venice) - Fondamente zattere - Casa Scarpa.jpg",
"author": "Didier Descouens",
"license": "CC BY-SA 4.0"
},
{
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 03.jpg",
"author": "Holger Uwe Schmitt",
"license": "CC BY-SA 4.0"
},
{
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 05.jpg",
"author": "Holger Uwe Schmitt",
"license": "CC BY-SA 4.0"
},
{
"file": "021 Wild smiling harbor seal at Jökulsárlón (Iceland) Photo by Giles Laurent.jpg",
"author": "Giles Laurent",
"license": "CC BY-SA 4.0"
},
{ "file": "03613 ISL Akureyri cruise vessel vapors V P.jpg", "author": "Virtual-Pano", "license": "CC BY-SA 4.0" },
{ "file": "20181110 Fushimi Inari shrine 3.jpg", "author": "Balon Greyjoy", "license": "CC0" },
{ "file": "20181110 Fushimi Inari shrine 4.jpg", "author": "Balon Greyjoy", "license": "CC0" },
{ "file": "20181110 Fushimi Inari shrine 6.jpg", "author": "Balon Greyjoy", "license": "CC0" },
{ "file": "2019-08-01 Zugspitze 7.jpg", "author": "Maksym Kozlenko", "license": "CC BY-SA 4.0" },
{
"file": "2022-05-28 13-21-39 Heinabergslón and Heinabergsjökull.jpg",
"author": "Hansueli Krapf",
"license": "CC BY-SA 4.0"
},
{ "file": "2022-05-28 13-26-26 Island - Hoffell 118.0.jpg", "author": "Hansueli Krapf", "license": "CC BY-SA 4.0" },
{
"file": "2022-05-28 13-50-35 Panorama of Heinabergsjökull.jpg",
"author": "Hansueli Krapf",
"license": "CC BY-SA 4.0"
},
{ "file": "2023-11-04 Lycian Way near Kaş.jpg", "author": "Alexkom000", "license": "CC BY 4.0" },
{ "file": "AbiskoNationalpark01.jpg", "author": "Unterrichter at English Wikipedia", "license": "Public domain" },
{ "file": "Almannagjá gorge at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
{ "file": "Almannagjá rift at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
{ "file": "Arashiyama Bamboo Grove (Unsplash).jpg", "author": "Erol Ahmed", "license": "CC0" },
{ "file": "Arashiyama Bamboo Grove.jpg", "author": "Mitchwandrew", "license": "CC BY 4.0" },
{ "file": "Arashiyama-Bamboo-Grove-Sunset.jpg", "author": "Bjørn Christian Tørrissen", "license": "CC BY-SA 4.0" },
{ "file": "ArashiyamaTogetsukyo Kyoto.jpg", "author": "Kentagon", "license": "CC BY-SA 4.0" },
{ "file": "Arnarstapi (port).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{ "file": "Arnarstapi 2.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
{ "file": "Arnarstapi 4.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
{
"file": "Averau from Fedare at Passo Giau, Belluno, Veneto, Italy, 2025 October.jpg",
"author": "Simo Räsänen",
"license": "CC BY-SA 4.0"
},
{ "file": "Bamboo Forest, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
{ "file": "Bamboo Grove, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
{ "file": "Basalt Arch near Arnarstapi.jpg", "author": "Remarksman", "license": "CC BY 2.0" },
{
"file": "Basilica di Santa Maria della Salute, Venice (37770336971).jpg",
"author": "Dimitris Kamaras from Athens, Greece",
"license": "CC BY 2.0"
},
{ "file": "Black Sand Beach Iceland, Reynisfjara Beach.jpg", "author": "miketnorton", "license": "CC BY 2.0" },
{ "file": "Bubbly Diamond on the Beach - Iceland.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
{
"file": "Burano Hausfassade Wäsche-20090315-RM-113017.jpg",
"author": "Reinhold Möller",
"license": "CC BY-SA 4.0"
},
{
"file": "Burano Hausfassade Wäsche-20090315-RM-113043.jpg",
"author": "Reinhold Möller",
"license": "CC BY-SA 4.0"
},
{ "file": "Burano Vendig Aug 2021 4.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
{ "file": "Café A Brasileira. Sculpture.jpg", "author": "MFREYNAUD51", "license": "CC BY-SA 4.0" },
{
"file": "Café de Flore, 172 boulevard Saint-Germain, Paris 6e.jpg",
"author": "Celette",
"license": "CC BY-SA 4.0"
},
{
"file": "Café Majestic - Porto - Portugal (53283088293).jpg",
"author": "Vitor Oliveira from Torres Vedras, PORTUGAL",
"license": "CC BY-SA 2.0"
},
{ "file": "Café Sperl, Wien.jpg", "author": "Jeremy Thompson", "license": "CC BY 2.0" },
{ "file": "Caffè San Marco-Trieste.jpg", "author": "Tiesse", "license": "Public domain" },
{ "file": "Caffè Sant'Eustachio.JPG", "author": "LPLT", "license": "CC BY-SA 3.0" },
{ "file": "Canale Grande in the morning light - panoramio.jpg", "author": "Schölla Schwarz", "license": "CC BY 3.0" },
{ "file": "Cinque Torri 01.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
{ "file": "Cinque Torri 02.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
{ "file": "Cinque Torri 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Cinque Torri, Dolomites - panoramio (1).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
{ "file": "Cinque Torri, Dolomites - panoramio (2).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
{
"file": "Colourful houses in Corte della Comare, Burano (detail).jpg",
"author": "Marco Ober",
"license": "CC BY-SA 4.0"
},
{ "file": "Croda del Becco and Rifugio Biella 202008.jpg", "author": "Marie-Lan Taÿ Pamart", "license": "CC BY 4.0" },
{
"file": "Cuernos del Paine in Torres del Paine National Park.jpg",
"author": "Pedro Szekely from Los Angeles, USA",
"license": "CC BY-SA 2.0"
},
{ "file": "Dettifoss 2026 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Dettifoss Panorama 01.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
{ "file": "Dettifoss Panorama 02.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
{ "file": "Dettifoss Panorama 03.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
{ "file": "Dorsoduro Rio dei Carmini in Venice.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Dotonbori Osaka Japan01-r.jpg", "author": "Oilstreet", "license": "CC BY 2.5" },
{ "file": "Dotonbori, Osaka, at night, November 2016.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Dyrholaey Arch.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
{
"file": "Dyrhólaey, seen from Reynisfjara, Iceland, 20240720 0831 2824.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{ "file": "Eibsee und Ammergauer Alpen.jpg", "author": "Octagon", "license": "CC BY 3.0" },
{
"file": "Ëur de Bredles Secëda Odles y Sas Rigais da Resciesa.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 4.0"
},
{ "file": "Faloria Cortina d'Ampezzo 10.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Falzarego-N.jpg", "author": "Supakon Mohr", "license": "CC BY-SA 3.0 de" },
{ "file": "Fimmvörðuháls (1).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{ "file": "Fimmvörðuháls (9).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{
"file": "Fimmvorduhals-emergency-hut.jpg",
"author": "Reen Eversdijk from Groningen, The Netherlands",
"license": "CC BY 2.0"
},
{ "file": "Fjaðrárgljúfur 04.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Forcella Nuvolau–Nuvolau lodge trail.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
{
"file": "Four ladies wearing a yukata in front of the North Gate of Kiyomizu-dera temple Kyoto Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{
"file": "Frea pass Sella group Pisciadu Dolomites South Tyrol.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 3.0"
},
{ "file": "Funivia del Lagazuoi (passo Falzarego).jpg", "author": "Ale041VE", "license": "CC BY-SA 4.0" },
{ "file": "Gion Hanamikôji-dôri.jpg", "author": "Yanajin33", "license": "CC BY-SA 4.0" },
{
"file": "Glacier du Mont Mallet (Glacier du Géant) & Mont Blanc, 2010 July.jpg",
"author": "Ximonic, Simo Räsänen",
"license": "CC BY-SA 3.0"
},
{ "file": "Godafoss 2.jpg", "author": "Tord Dellsen", "license": "CC0" },
{ "file": "Godafoss 3.jpg", "author": "Tord Dellsen", "license": "CC0" },
{ "file": "Godafoss 4.jpg", "author": "Tord Dellsen", "license": "CC0" },
{ "file": "Goðafoss Island.jpg", "author": "Superbass", "license": "CC BY-SA 3.0" },
{
"file": "Gondola Guido Lorenzi - Son Forca Cristallo Italy - panoramio.jpg",
"author": "Hiroki Ogawa",
"license": "CC BY 3.0"
},
{ "file": "GR20 Corsica Montains-2.jpg", "author": "Hermes from mars", "license": "CC BY-SA 3.0" },
{
"file": "Grand Canal Santa Maria della Salute Vaporetto Gondola Venice 01.jpg",
"author": "Julian Herzog",
"license": "CC BY 4.0"
},
{
"file": "Gullfoss, Suðurland, Islandia, 2014-08-16, DD 119.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Hallgrímskirkja 1.jpg", "author": "Dmitry Brant", "license": "CC BY-SA 4.0" },
{ "file": "Heinabergsjökull (38322450122).jpg", "author": "Markus Trienke", "license": "CC BY-SA 2.0" },
{ "file": "Hverfjall July 2014.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Hverir Námaskarð.jpg", "author": "JavierOlivares", "license": "CC BY-SA 4.0" },
{ "file": "Iceland Dettifoss 1972-4.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
{ "file": "Iceland Godafoss 1972.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
{ "file": "Il Sentiero degli dei.jpg", "author": "Stiflele", "license": "CC BY-SA 4.0" },
{
"file": "Illusion, Burano Island. (39893205060).jpg",
"author": "Mihai Lucîț from Baia Mare, Romania",
"license": "CC BY 2.0"
},
{ "file": "Islanda- Þórsmörk.jpg", "author": "Mario Falcetti", "license": "CC BY 4.0" },
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0935 2448.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0936 2452.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0937 2454.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0947 2477.jpg",
"author": "Jakub Hałun",
"license": "CC BY 4.0"
},
{
"file": "Kansai Airport (2660200665).jpg",
"author": "Kentaro Iemoto from Tokyo, Japan",
"license": "CC BY-SA 2.0"
},
{ "file": "Kinkaku-ji in November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{
"file": "Kinkaku-ji the Golden Temple in Kyoto overlooking the lake - high rez.JPG",
"author": "Jaycangel",
"license": "CC BY-SA 3.0"
},
{ "file": "Kirkjufell in Fog (Unsplash).jpg", "author": "Nicholas Kampouris", "license": "CC0" },
{ "file": "Kirkjufell with its reflection.jpg", "author": "_大面", "license": "CC BY 2.5" },
{ "file": "Kirkjufell, Iceland (Unsplash LoKG7kKx7Zc).jpg", "author": "Steven Diaz", "license": "CC0" },
{
"file": "Kirkjufell, Vesturland, Islandia, 2014-08-14, DD 091.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -01.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -03.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -06.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -07.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "KyotoFushimiInariLarge.jpg", "author": "Paul Vlaar", "license": "CC BY-SA 3.0" },
{ "file": "Lagazuoi 05.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
{ "file": "Lago alla base di Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Lago alla base di Svínafellsjökull 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 3.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 5.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 6.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 7.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Lago di Braies South Tyrol 9.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{
"file": "Lago di Limo, Parco Naturale Fanes - Sennes - Braies (BZ).jpg",
"author": "53cri021",
"license": "CC BY-SA 4.0"
},
{ "file": "Lagoa Rasa (Sete Cidades) 2.jpg", "author": "Walmaul", "license": "CC BY-SA 4.0" },
{ "file": "Lake Ashi & Mt Fuji & Hakone Shrine.jpg", "author": "Charlie fong", "license": "CC BY-SA 4.0" },
{ "file": "Landmannalaugar in summer 2009 (13).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
{
"file": "Manarola NW Cemetery Corniglia Monterosso Cinque Terre Sep23 A7C 06872.jpg",
"author": "Timothy A. Gonsalves",
"license": "CC BY-SA 4.0"
},
{ "file": "Meiji Jingu Shrine Tokyo Japan.jpg", "author": "MediaByPanda", "license": "CC BY-SA 4.0" },
{ "file": "Meiji shrine in August 2007 5.jpg", "author": "Syced", "license": "CC0" },
{ "file": "Meiji Shrine, Tokyo; April 2009 (02).jpg", "author": "MIKI Yoshihito", "license": "CC BY 2.0" },
{
"file": "Meiji-jingū main hall vue centrale.jpg",
"author": "Hyppolyte de Saint-Rambert",
"license": "CC BY-SA 4.0"
},
{ "file": "Meiji-jingu-pathway.jpg", "author": "Nightcrafter", "license": "CC BY-SA 4.0" },
{ "file": "Monte Kirkjufell (Luglio 2024).jpg", "author": "Paolova97", "license": "CC BY-SA 4.0" },
{ "file": "Morning light in Venice (33726527456).jpg", "author": "Hervé Simon", "license": "CC BY-SA 2.0" },
{ "file": "Mount Fuji and Shinkansen N700.jpg", "author": "Alpsdake", "license": "CC BY-SA 4.0" },
{
"file": "Mount Myojingatake and Owakudani Station from Owakudani Valley.JPG",
"author": "そらみみ",
"license": "CC BY-SA 3.0"
},
{ "file": "Mount-Fuji from Tokaido Shinkansen window view.jpg", "author": "Indiana jo", "license": "CC BY-SA 4.0" },
{
"file": "Mountain and grotto at Reynisfjara Beach in July, 2019.jpg",
"author": "Alexander Grebenkov",
"license": "CC BY 3.0"
},
{ "file": "Múlafossur2.jpg", "author": "Ekrem Canli", "license": "CC BY-SA 4.0" },
{ "file": "Neon Signs in Dotonbori, Osaka 20190415 1.jpg", "author": "DXR", "license": "CC BY-SA 4.0" },
{
"file": "Neunerspitze, 2968 m und Zehnerspitze 3016 m.jpg",
"author": "2015 Michael 2015",
"license": "CC BY-SA 4.0"
},
{ "file": "NeunerZehnerFanes.JPG", "author": "Alex1011", "license": "CC BY 2.5" },
{ "file": "Nishiki ichiba kyoto.jpg", "author": "Pitan", "license": "CC BY-SA 3.0" },
{ "file": "Old Tokaido - Hakone.jpg", "author": "Quercus acuta", "license": "CC BY-SA 4.0" },
{ "file": "Old Tokaido -01.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "On the hillside above Ginkaku-ji (2099332601).jpg", "author": "KimonBerlin", "license": "CC BY-SA 2.0" },
{ "file": "Osaka Dotonbori Canal.jpg", "author": "32linesky", "license": "CC BY-SA 4.0" },
{
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 1.JPG",
"author": "そらみみ",
"license": "CC BY-SA 3.0"
},
{
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 2.JPG",
"author": "そらみみ",
"license": "CC BY-SA 3.0"
},
{ "file": "Owakudani Valley near Owakudani Station 2.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
{ "file": "Owakudani Valley near Owakudani Station.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
{ "file": "Palazzo Balbi (Venice).jpg", "author": "Didier Descouens", "license": "CC BY-SA 3.0" },
{
"file": "Palazzo dei Camerlenghi e Ponte Rialto a Venezia.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 3.0"
},
{
"file": "Panorama of Canal Grande and Ponte di Rialto, Venice - September 2017.jpg",
"author": "Martin Falbisoner",
"license": "CC BY-SA 4.0"
},
{ "file": "Passo di Giau.jpg", "author": "Frisia Orientalis", "license": "CC BY-SA 3.0" },
{ "file": "Passo Giau SP638.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Photos at teamlab planets tokyo.jpg", "author": "Sasa0403", "license": "CC BY-SA 4.0" },
{ "file": "Ponte di Rialto facciata ovest di sera.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Ponte di Rialto Venice 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
{ "file": "Pontile Zattere (Venezia).jpg", "author": "Didier Descouens", "license": "CC BY-SA 4.0" },
{ "file": "Preikestolen Norge.jpg", "author": "Stefan Krause, Germany", "license": "CC BY-SA 3.0" },
{ "file": "Ra Gusela, view from Passo Giau.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
{ "file": "Reine i Lofoten LC0148.jpg", "author": "Jörg Hempel", "license": "CC BY-SA 2.0 de" },
{
"file": "Reynisfjara Black Sand Beach Iceland Travel Photography (210387065).jpeg",
"author": "Giuseppe Milo",
"license": "CC BY 3.0"
},
{
"file": "Reynisfjara, Suðurland, Islandia, 2014-08-17, DD 164.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{
"file": "Reyniskirkja Church,Vík í Mýrdal, Suðurland,Ísland.jpg",
"author": "JavierOlivares",
"license": "CC BY-SA 4.0"
},
{ "file": "Rifugio Fanes 01.JPG", "author": "Bbruno", "license": "CC BY-SA 3.0" },
{ "file": "Rifugio Fanes.jpg", "author": "Llorenzi", "license": "CC BY-SA 3.0" },
{ "file": "Rigi Kulm cross 20210905.jpg", "author": "Daniel Kraft", "license": "CC BY-SA 3.0" },
{
"file": "Roca de la Ley, Parque Nacional de Þingvellir, Suðurland, Islandia, 2014-08-16, DD 022.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Salzwelten vantage point.jpg", "author": "Grandmaster Huon", "license": "CC0" },
{ "file": "San Marco cathedral in Venice.JPG", "author": "Petar Milosevic", "license": "CC BY-SA 4.0" },
{
"file": "Santa Maria della Salute from Hotel Monaco nightview.jpg",
"author": "Wolfgang Moroder",
"license": "CC BY-SA 3.0"
},
{ "file": "Santa Maria della Salute from Hotel Monaco.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Seljalandsfoss - panoramio (7).jpg", "author": "Zoran Kurelić Rabko", "license": "CC BY-SA 3.0" },
{ "file": "Seljalandsfoss and Gljúfrabúi, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
{ "file": "Seljalandsfoss July 2014 -1.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
{ "file": "Seljalandsfoss view from side, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
{
"file": "Seljalandsfoss, Suðurland, Islandia, 2014-08-16, DD 189-191 HDR.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{
"file": "Senso-ji Temple @ Asakusa (13824517393).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Senso-ji Temple @ Asakusa (13824525803).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Senso-ji Temple @ Asakusa (13824839284).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Senso-ji Temple @ Asakusa (14019477806).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{ "file": "Seyðisfjörður church 1.jpg", "author": "Saifunny", "license": "CC BY-SA 4.0" },
{ "file": "Seyðisfjörður Sept 2019 1.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
{
"file": "Seydisfjordur, East Iceland (51440969579).jpg",
"author": "Fabio Achilli from Milano, Italy",
"license": "CC BY 2.0"
},
{
"file": "Seydisfjordur, East Iceland (51441184230).jpg",
"author": "Fabio Achilli from Milano, Italy",
"license": "CC BY 2.0"
},
{ "file": "Seyðisfjörður, Iceland (28328269217).jpg", "author": "Joe deSousa", "license": "CC0" },
{
"file": "Shadows and windows at Hallgrimskirkja Reykjavik Iceland 2026-02-07 21-21-01 1.jpg",
"author": "G. Edward Johnson",
"license": "CC BY 4.0"
},
{
"file": "Shariden, Kinkaku-ji, Kyoto, East View 20130811 1.jpg",
"author": "DXR / Daniel Vorndran",
"license": "CC BY-SA 3.0"
},
{ "file": "Shinjuku-West Omoide-Yokocho.jpg", "author": "MaedaAkihiko", "license": "CC BY-SA 4.0" },
{
"file": "Shinkansen N700S series photographed at Higashi-Tagonoura with Mount Fuji in the background, 10 April 2023.jpg",
"author": "Takeshi Aida",
"license": "CC BY-SA 2.0"
},
{ "file": "Skogafoss-02-2018-gje.jpg", "author": "Gerd Eichmann", "license": "CC BY-SA 4.0" },
{ "file": "Sky Lagoon Cold Plunge.jpg", "author": "Laurenmcl", "license": "CC BY-SA 4.0" },
{ "file": "Soca River Valley, Slovenia.jpg", "author": "Barry", "license": "CC BY 2.0" },
{
"file": "Start of the Old Hakone Highway (9412055176).jpg",
"author": "Maarten Heerlien from Voorschoten, The Netherlands",
"license": "CC BY 2.0"
},
{
"file": "Strokkur, Área geotérmica de Geysir, Suðurland, Islandia, 2014-08-16, DD 086.JPG",
"author": "Diego Delso",
"license": "CC BY-SA 4.0"
},
{ "file": "Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Svínafellsjökull glacier at sunset.jpg", "author": "Mikołaj Bańka", "license": "CC BY 4.0" },
{ "file": "Svínafellsjökull.JPG", "author": "Bromr at Dutch Wikipedia", "license": "CC BY-SA 3.0" },
{
"file": "Taroko-Gorge Hualien Taiwan Pudu-Bridge-at-Taroko-National-Park-01.jpg",
"author": "CEphoto, Uwe Aranas",
"license": "CC BY-SA 3.0"
},
{
"file": "The Lava Fields Of Þórsmörk Iceland (175491845).jpeg",
"author": "Ilya Grigorik",
"license": "CC BY-SA 3.0"
},
{ "file": "The park bench (2669222928).jpg", "author": "Rosino", "license": "CC BY-SA 2.0" },
{
"file": "The West Highland Way approaching the Heads of Glen Etive and Glen Coe - geograph.org.uk - 5014659.jpg",
"author": "Tim Heaton",
"license": "CC BY-SA 2.0"
},
{ "file": "Tokyo Shibuya Scramble Crossing 2018-10-09.jpg", "author": "Benh LIEU SONG", "license": "CC BY-SA 2.0" },
{
"file": "Torii on Lake Ashi @ Hakone Shrine (13776878594).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Torii on Lake Ashi @ Hakone Shrine (13776879364).jpg",
"author": "Guilhem Vellut from Annecy, France",
"license": "CC BY 2.0"
},
{
"file": "Torii path with lantern at Fushimi Inari Taisha Shrine, Kyoto, Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{ "file": "Trolltunga 2.jpg", "author": "Steinar Talmoen", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market -04.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market -05.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market -06.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
{ "file": "Tsukiji Outer Market 2.jpg", "author": "Aw1805", "license": "CC BY-SA 3.0" },
{ "file": "Ushguli Svaneti Georgia.JPG", "author": "Florian Pinel", "license": "CC BY-SA 3.0" },
{ "file": "Vaporetto grand canal venice.jpg", "author": "John Salatas", "license": "CC BY-SA 4.0" },
{ "file": "Vaporetto, Grand Canal, Venice.jpg", "author": "Peter K Burian", "license": "CC BY-SA 4.0" },
{ "file": "Venice, Canal Grande, Dorsoduro, Plzo Balbi.jpg", "author": "Armin Kleiner", "license": "CC BY-SA 4.0" },
{ "file": "Venice, Italy. Morning light.jpg", "author": "Dupont66", "license": "CC BY 4.0" },
{ "file": "Via ferrata Ivano Dibona - panoramio (1).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
{ "file": "Via ferrata Ivano Dibona - panoramio (2).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
{ "file": "Via Ferrato Ivano Dibona 3.jpg", "author": "Schi11", "license": "CC BY-SA 4.0" },
{ "file": "View from Miradouro do Pico do Arieiro - Madeira 05.jpg", "author": "H. Zell", "license": "CC BY-SA 3.0" },
{ "file": "Vire via ferrata Ivano Dibona.jpg", "author": "Evelyne Chassagneux-Bonini", "license": "CC BY-SA 4.0" },
{
"file": "Water reflection of Kinkaku-ji Temple a sunny day, Kyoto, Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{
"file": "Water reflection of Kinkaku-ji Temple with blue sky and white clouds, Kyoto, Japan.jpg",
"author": "Basile Morin",
"license": "CC BY-SA 4.0"
},
{ "file": "Water transport in Venice at Rialto bridge.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
{ "file": "Waterfalls of Iceland 23 02.jpg", "author": "Hornstrandir1", "license": "CC BY-SA 4.0" },
{ "file": "Wengental 2019-10-18.jpg", "author": "2015 Michael 2015", "license": "CC BY-SA 4.0" },
{ "file": "Wien, Café Central -- 2018 -- 3135.jpg", "author": "Dietmar Rabich", "license": "CC BY-SA 4.0" },
{ "file": "Yanaka Ginza 2.jpg", "author": "Christophe95", "license": "CC BY-SA 4.0" },
{
"file": "Yanaka Ginza shopping street (4041690725).jpg",
"author": "Yoshikazu TAKADA from Tokyo, Japan",
"license": "CC BY 2.0"
},
{ "file": "Yanaka ginza street.jpg", "author": "Kentin", "license": "CC BY-SA 3.0" },
{ "file": "Yanaka Ginza, Tokyo, Japan (6790100548).jpg", "author": "yeowatzup", "license": "CC BY 2.0" },
{ "file": "Þingvellir, Almannagjá 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "Þingvellir, Almannagjá 07.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
{ "file": "関西国際空港全体写真20220811.jpg", "author": "Ankou1192", "license": "CC BY-SA 4.0" }
]
@@ -0,0 +1,35 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useDemoAccountLock } from './useDemoLock';
beforeEach(() => resetAllStores());
describe('useDemoAccountLock', () => {
it('FE-HOOK-DEMOLOCK-001: locks the demo account on a demo instance', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
});
it('FE-HOOK-DEMOLOCK-002: knows the address the demo account had before the rename', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@nomad.app' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
});
it('FE-HOOK-DEMOLOCK-003: leaves the admin of a demo instance alone', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
});
it('FE-HOOK-DEMOLOCK-004: leaves the demo address alone on an instance that is not a demo', () => {
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
});
it('FE-HOOK-DEMOLOCK-005: is off while nobody is signed in', () => {
seedStore(useAuthStore, { demoMode: true, user: null });
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { isDemoAccountEmail } from '@trek/shared';
import { useAuthStore } from '../../store/authStore';
/**
* Whether the account signed in here is the shared demo account of a demo
* instance. Every visitor signs in as that one account, so its Account tab,
* API keys, MCP configuration, own AI connection and language sit under a
* DemoLock; the server refuses the same writes (`demo-write.ts`). The admin
* of a demo instance, and every account of a real one, see the sections as
* they are.
*/
export function useDemoAccountLock(): boolean {
const demoMode = useAuthStore((s) => s.demoMode);
const email = useAuthStore((s) => s.user?.email);
return demoMode && isDemoAccountEmail(email);
}
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { useAuthStore } from '../../store/authStore';
import { formatCountdown, secondsUntilReset } from './demoWelcomeModel';
/**
* Whether the photo credits have the dialog: not yet, now, or no longer
* (the visitor came back from them, and the focus returns to their link).
*/
type CreditsView = 'unseen' | 'open' | 'left';
/**
* The welcome dialog's state: whether it is still open, whether it shows the
* photo credits in place of the welcome, and the live countdown to the next
* hourly reset, ticking once a second in the server's time zone.
*/
export function useDemoWelcome() {
const timeZone = useAuthStore((s) => s.serverTimezone);
const [open, setOpen] = useState(true);
const [credits, setCredits] = useState<CreditsView>('unseen');
const [secondsLeft, setSecondsLeft] = useState(() => secondsUntilReset(new Date(), timeZone));
useEffect(() => {
if (!open) return;
const timer = setInterval(() => setSecondsLeft(secondsUntilReset(new Date(), timeZone)), 1000);
return () => clearInterval(timer);
}, [open, timeZone]);
return {
open,
close: () => setOpen(false),
countdown: formatCountdown(secondsLeft),
creditsOpen: credits === 'open',
backFromCredits: credits === 'left',
openCredits: () => setCredits('open'),
closeCredits: () => setCredits('left'),
};
}
@@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
import type { JourneyMapGLHandle } from './JourneyMapGL'
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
import { useGlRenderer } from '../Map/glSupport'
import type { JourneyTrack } from '@trek/shared'
import type { MapPhoto } from './journeyPhotoLayer'
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
@@ -56,10 +57,11 @@ function JourneyMapAuto({ ref, ...props }: Props) {
const leafletRef = useRef<JourneyMapHandle>(null)
const glRef = useRef<JourneyMapGLHandle>(null)
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
// Fall back to Leaflet when the user selected Mapbox GL but hasn't supplied a
// token yet (MapLibre/OpenFreeMap is tokenless), or when the browser has no WebGL2.
const glRenderer = useGlRenderer(provider, token)
const useGL = glRenderer !== null
const glProvider = glRenderer ?? 'mapbox-gl'
useImperativeHandle(ref, () => ({
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
@@ -107,7 +107,8 @@ const glModule = vi.hoisted(() => () => ({
}))
vi.mock('mapbox-gl', glModule)
vi.mock('maplibre-gl', glModule)
// maplibre-gl 6 has no default export: the same fakes, as named exports.
vi.mock('maplibre-gl', () => glModule().default)
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
@@ -121,7 +122,7 @@ vi.mock('../Map/mapboxSetup', () => ({
import React from 'react'
import mapboxgl from 'mapbox-gl'
import maplibregl from 'maplibre-gl'
import * as maplibregl from 'maplibre-gl'
import { render, screen, act } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
@@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
@@ -360,11 +361,13 @@ function JourneyMapGL(
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: JourneyMapAuto has switched to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
marksFramedRef.current = false
entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])]
@@ -1,116 +0,0 @@
import { act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import DemoBanner from './DemoBanner';
describe('DemoBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
// FE-COMP-DEMOBANNER-001
it('renders without crashing', () => {
render(<DemoBanner />);
expect(document.body).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-002
it('overlay is visible on initial render with dismiss button', () => {
render(<DemoBanner />);
expect(screen.getByText('Got it')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-003
it('shows English welcome title by default', () => {
render(<DemoBanner />);
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-004
it('clicking "Got it" dismisses the banner', async () => {
const user = userEvent.setup();
render(<DemoBanner />);
const button = screen.getByText('Got it');
await user.click(button);
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-005
it('clicking the overlay backdrop dismisses the banner', () => {
const { container } = render(<DemoBanner />);
// The outermost fixed div is the overlay backdrop
const overlay = container.firstChild as HTMLElement;
fireEvent.click(overlay);
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-006
it('clicking the inner card does NOT dismiss', async () => {
const user = userEvent.setup();
render(<DemoBanner />);
// The inner card is the direct parent of the "Got it" button's container
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
await user.click(card);
expect(screen.getByText('Got it')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-007
it('shows reset timer', () => {
render(<DemoBanner />);
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-008
it('shows upload-disabled notice', () => {
render(<DemoBanner />);
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-009
it('shows "What is TREK?" section', () => {
render(<DemoBanner />);
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-010
it('shows addon cards', () => {
render(<DemoBanner />);
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-011
it('shows full version features section', () => {
render(<DemoBanner />);
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-012
it('self-host link points to GitHub', () => {
render(<DemoBanner />);
const link = screen.getByText('self-host it').closest('a')!;
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
expect(link).toHaveAttribute('target', '_blank');
});
// Timer update test
it('updates countdown timer after interval tick', async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
render(<DemoBanner />);
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
await act(async () => {
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
vi.advanceTimersByTime(10000);
});
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
});
});
-613
View File
@@ -1,613 +0,0 @@
import {
ArrowRightLeft,
CalendarDays,
Clock,
Database,
FileText,
Github,
Globe,
Key,
ListChecks,
Map,
Puzzle,
Shield,
Upload,
Users,
Wallet,
} from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
interface DemoTexts {
titleBefore: string;
titleAfter: string;
title: string;
description: string;
resetIn: string;
minutes: string;
uploadNote: string;
fullVersionTitle: string;
features: string[];
addonsTitle: string;
addons: [string, string][];
whatIs: string;
whatIsDesc: string;
selfHost: string;
selfHostLink: string;
close: string;
}
const texts: Record<string, DemoTexts> = {
de: {
titleBefore: 'Willkommen bei ',
titleAfter: '',
title: 'Willkommen zur TREK Demo',
description:
'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
resetIn: 'Naechster Reset in',
minutes: 'Minuten',
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
fullVersionTitle: 'In der Vollversion zusaetzlich:',
features: [
'Datei-Uploads (Fotos, Dokumente, Cover)',
'API-Schluessel (Google Maps, Wetter)',
'Benutzer- & Rechteverwaltung',
'Automatische Backups',
'Addon-Verwaltung (aktivieren/deaktivieren)',
'OIDC / SSO Single Sign-On',
],
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
addons: [
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
['Packliste', 'Checklisten pro Reise'],
['Budget', 'Kostenplanung mit Splitting'],
['Dokumente', 'Dateien an Reisen anhaengen'],
['Widgets', 'Waehrungsrechner & Zeitzonen'],
],
whatIs: 'Was ist TREK?',
whatIsDesc:
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten',
close: 'Verstanden',
},
en: {
titleBefore: 'Welcome to ',
titleAfter: '',
title: 'Welcome to the TREK Demo',
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
resetIn: 'Next reset in',
minutes: 'minutes',
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
fullVersionTitle: 'Additionally in the full version:',
features: [
'File uploads (photos, documents, covers)',
'API key management (Google Maps, Weather)',
'User & permission management',
'Automatic backups',
'Addon management (enable/disable)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Modular Addons (can be deactivated in full version)',
addons: [
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
['Atlas', 'World map with visited countries & travel stats'],
['Packing', 'Checklists per trip'],
['Budget', 'Expense tracking with splitting'],
['Documents', 'Attach files to trips'],
['Widgets', 'Currency converter & timezones'],
],
whatIs: 'What is TREK?',
whatIsDesc:
'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
selfHost: 'Open source — ',
selfHostLink: 'self-host it',
close: 'Got it',
},
et: {
titleBefore: 'Tere tulemast ',
titleAfter: 'i',
title: 'Tere tulemast TREKi demosse',
description: 'Saad reise vaadata, muuta ja luua. Kõik muudatused lähtestatakse automaatselt iga tunni järel.',
resetIn: 'Järgmise lähtestamiseni',
minutes: 'minutit',
uploadNote: 'Failide (fotode, dokumentide ja kaanepiltide) üleslaadimine on demorežiimis keelatud.',
fullVersionTitle: 'Täisversioonis lisaks:',
features: [
'Failide üleslaadimine (fotod, dokumendid, kaanepildid)',
'API-võtmete haldus (Google Maps, ilm)',
'Kasutajate ja õiguste haldus',
'Automaatsed varukoopiad',
'Lisamoodulite haldus (lubamine/keelamine)',
'OIDC / SSO ühekordne sisselogimine',
],
addonsTitle: 'Lisamoodulid (täisversioonis saab välja lülitada)',
addons: [
['Vacay', 'Puhkuseplaneerija kalendri, pühade ja kasutajate plaanide ühendamisega'],
['Atlas', 'Maailmakaart külastatud riikide ja reisistatistikaga'],
['Pakkimine', 'Iga reisi kontrollnimekirjad'],
['Eelarve', 'Kulude jälgimine ja jagamine'],
['Dokumendid', 'Lisa reisidele faile'],
['Vidinad', 'Valuutakalkulaator ja ajavööndid'],
],
whatIs: 'Mis on TREK?',
whatIsDesc:
'Ise majutatav reisiplaneerija reaalajas koostöö, interaktiivsete kaartide, OIDC sisselogimise ja tumeda režiimiga.',
selfHost: 'Avatud lähtekood — ',
selfHostLink: 'majuta ise',
close: 'Selge',
},
es: {
titleBefore: 'Bienvenido a ',
titleAfter: '',
title: 'Bienvenido a la demo de TREK',
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
resetIn: 'Próximo reinicio en',
minutes: 'minutos',
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
fullVersionTitle: 'Además, en la versión completa:',
features: [
'Subida de archivos (fotos, documentos, portadas)',
'Gestión de claves API (Google Maps, tiempo)',
'Gestión de usuarios y permisos',
'Copias de seguridad automáticas',
'Gestión de addons (activar/desactivar)',
'Inicio de sesión único OIDC / SSO',
],
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
addons: [
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
['Equipaje', 'Listas de comprobación para cada viaje'],
['Presupuesto', 'Control de gastos con reparto'],
['Documentos', 'Adjunta archivos a los viajes'],
['Widgets', 'Conversor de divisas y zonas horarias'],
],
whatIs: '¿Qué es TREK?',
whatIsDesc:
'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
selfHost: 'Código abierto — ',
selfHostLink: 'alójalo tú mismo',
close: 'Entendido',
},
zh: {
titleBefore: '欢迎来到 ',
titleAfter: '',
title: '欢迎来到 TREK 演示版',
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
resetIn: '下次重置将在',
minutes: '分钟后',
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
fullVersionTitle: '完整版本还包括:',
features: [
'文件上传(照片、文档、封面)',
'API 密钥管理(Google Maps、天气)',
'用户和权限管理',
'自动备份',
'附加组件管理(启用/禁用)',
'OIDC / SSO 单点登录',
],
addonsTitle: '模块化附加组件(完整版本可禁用)',
addons: [
['Vacay', '带日历、节假日和用户融合的假期规划器'],
['Atlas', '带已访问国家和旅行统计的世界地图'],
['Packing', '按旅行管理清单'],
['Budget', '支持分摊的费用追踪'],
['Documents', '将文件附加到旅行'],
['Widgets', '货币换算和时区工具'],
],
whatIs: '什么是 TREK?',
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
selfHost: '开源项目 - ',
selfHostLink: '自行部署',
close: '知道了',
},
'zh-TW': {
titleBefore: '歡迎來到 ',
titleAfter: '',
title: '歡迎來到 TREK 展示版',
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
resetIn: '下次重設將在',
minutes: '分鐘後',
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
fullVersionTitle: '完整版本還包含:',
features: [
'檔案上傳(照片、文件、封面)',
'API 金鑰管理(Google Maps、天氣)',
'使用者與權限管理',
'自動備份',
'附加元件管理(啟用/停用)',
'OIDC / SSO 單一登入',
],
addonsTitle: '模組化附加元件(完整版本可停用)',
addons: [
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
['Packing', '依行程管理的檢查清單'],
['Budget', '支援分攤的費用追蹤'],
['Documents', '將檔案附加到行程'],
['Widgets', '貨幣換算與時區工具'],
],
whatIs: 'TREK 是什麼?',
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
selfHost: '開源專案 - ',
selfHostLink: '自行架設',
close: '知道了',
},
ar: {
titleBefore: 'مرحبًا بك في ',
titleAfter: '',
title: 'مرحبًا بك في النسخة التجريبية من TREK',
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
resetIn: 'إعادة الضبط التالية خلال',
minutes: 'دقيقة',
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
features: [
'رفع الملفات (الصور والمستندات وصور الغلاف)',
'إدارة مفاتيح API (خرائط Google والطقس)',
'إدارة المستخدمين والصلاحيات',
'نسخ احتياطية تلقائية',
'إدارة الإضافات (تفعيل/تعطيل)',
'تسجيل دخول موحد OIDC / SSO',
],
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
addons: [
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
['Packing', 'قوائم تجهيز لكل رحلة'],
['Budget', 'تتبع المصروفات مع التقسيم'],
['Documents', 'إرفاق الملفات بالرحلات'],
['Widgets', 'محول عملات ومناطق زمنية'],
],
whatIs: 'ما هو TREK؟',
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
selfHost: 'مفتوح المصدر — ',
selfHostLink: 'استضفه بنفسك',
close: 'فهمت',
},
id: {
titleBefore: 'Selamat datang di ',
titleAfter: '',
title: 'Selamat datang di Demo TREK',
description:
'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
resetIn: 'Atur ulang berikutnya dalam',
minutes: 'menit',
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
fullVersionTitle: 'Selain itu dalam versi lengkap:',
features: [
'Unggah file (foto, dokumen, sampul)',
'Manajemen kunci API (Google Maps, Cuaca)',
'Manajemen pengguna & izin',
'Pencadangan otomatis',
'Manajemen Addon (aktifkan/nonaktifkan)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
addons: [
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
['Pengepakan', 'Daftar periksa per perjalanan'],
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
['Dokumen', 'Lampirkan file ke perjalanan'],
['Widget', 'Konverter mata uang & zona waktu'],
],
whatIs: 'Apa itu TREK?',
whatIsDesc:
'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
selfHost: 'Buka sumber — ',
selfHostLink: 'host mandiri',
close: 'Mengerti',
},
};
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield];
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft];
export default function DemoBanner(): React.ReactElement | null {
const [dismissed, setDismissed] = useState<boolean>(false);
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes());
const { language } = useTranslation();
const t = texts[language] || texts.en;
useEffect(() => {
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
return () => clearInterval(interval);
}, []);
if (dismissed) return null;
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99999,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingTop: 'max(16px, env(safe-area-inset-top))',
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
paddingInline: 16,
overflow: 'auto',
fontFamily: 'var(--font-system)',
}}
role="button"
tabIndex={0}
aria-label={t.close}
onClick={() => setDismissed(true)}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDismissed(true);
}
}}
>
<div
role="presentation"
style={{
background: 'white',
borderRadius: 20,
padding: '28px 24px 0',
maxWidth: 480,
width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: 'min(90vh, calc(100dvh - 96px))',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
<h2
style={{
margin: 0,
fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))',
fontWeight: 700,
color: '#111827',
display: 'flex',
alignItems: 'center',
gap: 5,
}}
>
{t.titleBefore}
<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />
{t.titleAfter}
</h2>
</div>
<p
style={{
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
color: '#6b7280',
lineHeight: 1.6,
margin: '0 0 12px',
}}
>
{t.description}
</p>
{/* Timer + Upload note */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 6,
background: '#f0f9ff',
border: '1px solid #bae6fd',
borderRadius: 10,
padding: '8px 10px',
}}
>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes}
</span>
</div>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 6,
background: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 10,
padding: '8px 10px',
}}
>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>
{t.uploadNote}
</span>
</div>
</div>
{/* What is TREK */}
<div
style={{
background: '#f8fafc',
borderRadius: 12,
padding: '12px 14px',
marginBottom: 16,
border: '1px solid #e2e8f0',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} />
<span
style={{
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 700,
color: '#111827',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{t.whatIs}
</span>
</div>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>
{t.whatIsDesc}
</p>
</div>
{/* Addons */}
<p
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 700,
color: '#374151',
margin: '0 0 8px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Puzzle size={12} />
{t.addonsTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.addons.map(([name, desc], i) => {
const Icon = addonIcons[i];
return (
<div
key={name}
style={{
background: '#f8fafc',
borderRadius: 10,
padding: '8px 10px',
border: '1px solid #f1f5f9',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}
>
{name}
</span>
</div>
<p
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
color: '#94a3b8',
margin: 0,
lineHeight: 1.3,
paddingInlineStart: 18,
}}
>
{desc}
</p>
</div>
);
})}
</div>
{/* Full version features */}
<p
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 700,
color: '#374151',
margin: '0 0 8px',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<Shield size={12} />
{t.fullVersionTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.features.map((text, i) => {
const Icon = featureIcons[i];
return (
<div
key={text}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: '#4b5563',
padding: '4px 0',
}}
>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span>
</div>
);
})}
</div>
{/* Footer */}
<div
style={{
padding: '14px 0 20px',
borderTop: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'sticky',
bottom: 0,
background: 'white',
marginTop: 'auto',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: '#9ca3af',
}}
>
<Github size={13} />
<span>{t.selfHost}</span>
<a
href="https://github.com/liketrek/TREK"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
>
{t.selfHostLink}
</a>
</div>
<button type="button"
onClick={() => setDismissed(true)}
style={{
background: '#111827',
color: 'white',
border: 'none',
borderRadius: 10,
padding: '8px 20px',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{t.close}
</button>
</div>
</div>
</div>
);
}
+5 -5
View File
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
import ErrorBoundary from '../shared/ErrorBoundary'
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
import { useGlRenderer } from './glSupport'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
@@ -28,11 +29,10 @@ export function MapViewAuto(props: MapViewAutoProps) {
const mapProps = { ...props, hazards: hazards.feed?.hazards } as MapViewProps
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token.
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
: null
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
// browser has no WebGL2 (both GL engines need it), so the trip planner never
// shows an empty map or one that fails to start.
const glProvider = useGlRenderer(provider, token)
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
// maplibre-gl out of each other's downloads.
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
@@ -0,0 +1,88 @@
/**
* The trip map without WebGL2 (FE-COMP-MAPAUTO-WEBGL-*).
*
* A browser that cannot give a GL engine its WebGL2 context sees the Leaflet map
* with the user's tiles, never a map that fails to start: up front when the probe
* says no, and the moment the GL map's own constructor is refused when it said yes.
*/
import { useEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { buildGlMap } from './glSupport';
import { MapViewAuto } from './MapViewAuto';
const gl = vi.hoisted(() => ({ refuse: false }));
vi.mock('./MapView', () => ({
MapView: () => <div data-testid="leaflet-map" />,
}));
/** Stands in for MapViewGL: builds its map the way the real one does, through buildGlMap. */
function FakeGlMap() {
useEffect(() => {
buildGlMap(() => {
if (!gl.refuse) return {};
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
throw err;
});
}, []);
return <div data-testid="gl-map" />;
}
vi.mock('./glLazy', () => ({ MapViewGLMaplibre: FakeGlMap, MapViewGLMapbox: FakeGlMap }));
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
const realGetContext = HTMLCanvasElement.prototype.getContext;
function browserWithWebGL2(available: boolean) {
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
available && type === 'webgl2' ? ctx : null
) as unknown as typeof realGetContext;
}
beforeEach(() => {
resetAllStores();
resetWebGLProbe();
gl.refuse = false;
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', mapbox_access_token: '' } });
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
describe('MapViewAuto without WebGL2', () => {
it('FE-COMP-MAPAUTO-WEBGL-001: draws the MapLibre map where WebGL2 is there', async () => {
browserWithWebGL2(true);
render(<MapViewAuto places={[]} />);
expect(await screen.findByTestId('gl-map')).toBeInTheDocument();
expect(screen.queryByTestId('leaflet-map')).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-WEBGL-002: draws the Leaflet map when the browser has no WebGL2', () => {
browserWithWebGL2(false);
render(<MapViewAuto places={[]} />);
expect(screen.getByTestId('leaflet-map')).toBeInTheDocument();
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-WEBGL-003: falls back to Leaflet when the GPU refuses the context the probe promised', async () => {
browserWithWebGL2(true);
gl.refuse = true;
render(<MapViewAuto places={[]} />);
expect(await screen.findByTestId('leaflet-map')).toBeInTheDocument();
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import { act } from '@testing-library/react';
import maplibregl from 'maplibre-gl';
import * as maplibregl from 'maplibre-gl';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
@@ -164,22 +164,21 @@ vi.mock('mapbox-gl', () => ({
}));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
// maplibre-gl 6 has no default export, so the fakes are named exports.
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
},
Map: vi.fn(function () {
return glMap;
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts);
}),
LngLatBounds: vi.fn(function () {
return glBounds.create();
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup;
}),
}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
+17 -18
View File
@@ -8,7 +8,7 @@ import { buildPlace } from '../../../tests/helpers/factories'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import maplibregl from 'maplibre-gl'
import * as maplibregl from 'maplibre-gl'
import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
@@ -156,22 +156,21 @@ vi.mock('mapbox-gl', () => ({
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
// maplibre-gl 6 has no default export, so the fakes are named exports.
vi.mock('maplibre-gl', () => ({
default: {
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts)
}),
LngLatBounds: vi.fn(function () {
return glBounds.create()
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup
}),
},
Map: vi.fn(function () {
return glMap
}),
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
return glMarkers.make(opts)
}),
LngLatBounds: vi.fn(function () {
return glBounds.create()
}),
NavigationControl: vi.fn(),
Popup: vi.fn(function () {
return glPopup
}),
}))
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
@@ -352,7 +351,7 @@ describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
const maplibregl = await import('maplibre-gl')
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
@@ -373,7 +372,7 @@ describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-014: MapLibre maps disable the around-center mouse rotate (#1545)', async () => {
const mapboxgl = (await import('mapbox-gl')).default
const maplibregl = (await import('maplibre-gl')).default
const maplibregl = await import('maplibre-gl')
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
+5 -5
View File
@@ -24,6 +24,7 @@ import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import { buildGlMap } from './glSupport'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -1104,13 +1105,12 @@ export function MapViewGL({
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// MapLibre 5's mouse-rotate inverts its sign at a mid-screen line it gets by
// re-projecting the map center — a line that drifts with the bearing, so a
// right-button drag near mid-screen ping-pongs instead of rotating (#1545).
// aroundCenter: false restores the plain dx-based rotate mapbox-gl uses.
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: MapViewAuto has switched to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
popupRef.current = new gl.Popup({
closeButton: false,
+4 -1
View File
@@ -7,7 +7,8 @@ import {
RASTER_FALLBACK_TILE_URL,
attributionForTile,
} from '../../constants/mapDefaults'
import { hasWebGL } from '../../utils/webgl'
import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
import { isGpuInitFailure } from './glSupport'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
/** The Leaflet layer maplibre-gl-leaflet hands back. */
@@ -96,6 +97,8 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
layer.addTo(map)
} catch (err) {
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
// The GPU, not a context budget: the GL maps elsewhere in the app switch to Leaflet too.
if (isGpuInitFailure(err)) reportWebGLFailure()
detachBasemapLayer(layer)
return false
}
+18 -6
View File
@@ -1,7 +1,6 @@
import maplibregl from 'maplibre-gl'
import * as maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
import { registerRtlTextPlugin } from '../rtlText'
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
/**
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
@@ -11,9 +10,22 @@ import { registerRtlTextPlugin } from '../rtlText'
* Popup, LngLatBounds — which is why the components take the engine as a prop and
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
* on the provider (projection, accessToken, aroundCenter).
*
* MapLibre 6 ships as ES modules only and has no default export any more, so the
* namespace is what goes out as this module's default. It is a frozen module
* namespace: nothing may assign to it, which is why the accessToken writes stay
* behind their mapbox-only guard.
*/
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
// MapLibre 6 finds its worker next to its own module, which inside a bundle is a
// chunk in /assets/ with no worker beside it. Vite builds the worker into its own
// file and hands back the URL; set before any Map is constructed, the
// maplibre-gl-leaflet bridge included, since it only ever reaches the engine
// together with this module.
maplibregl.setWorkerUrl(workerUrl)
// No RTL text plugin here, unlike mapbox. MapLibre 6.9 shapes Arabic and orders
// bidirectional text itself; the old plugin would replace that implementation, and
// a v6 worker can only load the plugin's UMD build through eval.
export default maplibregl
@@ -1,29 +1,33 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The two lines this fix actually adds. Everything else is a helper: the guard in
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting either registration, swapping an
* argument or dropping the deferred flag left the whole suite green.
* What each engine module does on import. Everything else is a helper: the guard
* in rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
* working fix from the outside. Deleting the mapbox registration, swapping an
* argument or dropping the deferred flag left the whole suite green, and so would
* a maplibre engine that forgot its worker URL.
*
* The engines are mocked because importing them for real builds a worker pool
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
*/
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
const { mapboxSet, maplibreSet, maplibreWorker } = vi.hoisted(() => ({
mapboxSet: vi.fn(),
maplibreSet: vi.fn(),
maplibreWorker: vi.fn(),
}));
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
vi.mock('maplibre-gl', () => ({ setRTLTextPlugin: maplibreSet, setWorkerUrl: maplibreWorker }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url', () => ({ default: '/assets/maplibre-worker.js' }));
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
beforeEach(() => {
vi.resetModules();
mapboxSet.mockReset();
maplibreSet.mockReset();
maplibreWorker.mockReset();
});
describe('the GL engines register the RTL text plugin (#2235)', () => {
@@ -37,13 +41,12 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
});
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
it('FE-MAP-RTL-002: importing the maplibre engine registers no plugin', async () => {
await import('./maplibre');
expect(maplibreSet).toHaveBeenCalledTimes(1);
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
// the call stays with each engine instead of going through one wrapper.
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
// MapLibre 6.9 shapes right-to-left text itself. The plugin would replace
// that, and a v6 worker could only run its UMD build through eval.
expect(maplibreSet).not.toHaveBeenCalled();
});
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
@@ -54,13 +57,13 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
expect(engine.default).toBeDefined();
});
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
it('FE-MAP-RTL-004: importing the maplibre engine points it at the bundled worker', async () => {
const engine = await import('./maplibre');
expect(engine.default).toBeDefined();
// Give the rejection a tick to go unhandled if nothing caught it.
await new Promise((resolve) => setTimeout(resolve, 0));
// MapLibre 6 looks for its worker next to its own module, which in a bundle
// is a chunk with no worker beside it. Without this no map draws a tile.
expect(maplibreWorker).toHaveBeenCalledTimes(1);
expect(maplibreWorker).toHaveBeenCalledWith('/assets/maplibre-worker.js');
expect(engine.default.setWorkerUrl).toBe(maplibreWorker);
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Whether a map may be drawn with a GL engine (FE-MAP-GLSUP-*).
*
* MapLibre 6 and Mapbox GL 3 throw out of their Map constructor without WebGL2.
* What is under test is the one decision every GL map goes through: the user's
* provider when it can run here, Leaflet otherwise, and Leaflet from the moment a
* GPU that claimed WebGL2 refuses the context after all.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasWebGL, resetWebGLProbe } from '../../utils/webgl';
import { buildGlMap, isGpuInitFailure, pickGlRenderer, useGlRenderer, useWebGL2 } from './glSupport';
const realGetContext = HTMLCanvasElement.prototype.getContext;
/** A browser whose canvas hands out a webgl2 context, or none. */
function browserWithWebGL2(available: boolean) {
const ctx = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) };
const getContext = vi.fn((type: string) => (available && type === 'webgl2' ? ctx : null));
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext;
return getContext;
}
function gpuError(): Error {
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
return err;
}
beforeEach(() => {
resetWebGLProbe();
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
describe('pickGlRenderer', () => {
it('FE-MAP-GLSUP-001: keeps the GL provider the user picked when WebGL2 is there', () => {
expect(pickGlRenderer('maplibre-gl', '', () => true)).toBe('maplibre-gl');
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => true)).toBe('mapbox-gl');
});
it('FE-MAP-GLSUP-002: draws with Leaflet when the browser has no WebGL2', () => {
expect(pickGlRenderer('maplibre-gl', '', () => false)).toBeNull();
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => false)).toBeNull();
});
it('FE-MAP-GLSUP-003: Mapbox without a token stays on Leaflet, MapLibre needs none', () => {
expect(pickGlRenderer('mapbox-gl', '', () => true)).toBeNull();
expect(pickGlRenderer('maplibre-gl', null, () => true)).toBe('maplibre-gl');
});
it('FE-MAP-GLSUP-004: a Leaflet user never pays for the probe', () => {
const webgl2 = vi.fn(() => true);
expect(pickGlRenderer('leaflet', 'pk.token', webgl2)).toBeNull();
expect(pickGlRenderer(undefined, undefined, webgl2)).toBeNull();
expect(webgl2).not.toHaveBeenCalled();
});
});
describe('isGpuInitFailure', () => {
it('FE-MAP-GLSUP-005: knows both engines’ way of saying the GPU refused', () => {
expect(isGpuInitFailure(gpuError())).toBe(true);
expect(isGpuInitFailure(new Error('Failed to initialize WebGL.'))).toBe(true);
});
it('FE-MAP-GLSUP-006: anything else is not a GPU failure', () => {
expect(isGpuInitFailure(new TypeError('container is null'))).toBe(false);
expect(isGpuInitFailure('Failed to initialize WebGL')).toBe(false);
expect(isGpuInitFailure(null)).toBe(false);
});
});
describe('buildGlMap', () => {
it('FE-MAP-GLSUP-007: hands back the map the engine built', () => {
const map = { remove: vi.fn() };
expect(buildGlMap(() => map)).toBe(map);
});
it('FE-MAP-GLSUP-008: a GPU that claimed WebGL2 and refused it gives null and turns the answer to no', () => {
browserWithWebGL2(true);
expect(hasWebGL()).toBe(true);
expect(
buildGlMap(() => {
throw gpuError();
})
).toBeNull();
expect(hasWebGL()).toBe(false);
});
it('FE-MAP-GLSUP-009: any other error is a bug and is thrown on', () => {
browserWithWebGL2(true);
expect(() =>
buildGlMap(() => {
throw new TypeError('style is undefined');
})
).toThrow('style is undefined');
expect(hasWebGL()).toBe(true);
});
});
describe('useGlRenderer', () => {
it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => {
const getContext = browserWithWebGL2(false);
const { result } = renderHook(() => useGlRenderer('maplibre-gl', ''));
expect(result.current).toBeNull();
expect(getContext.mock.calls.map((call) => call[0])).toEqual(['webgl2']);
});
it('FE-MAP-GLSUP-011: switches every mounted map to Leaflet once a GL map fails to build', () => {
browserWithWebGL2(true);
const trip = renderHook(() => useGlRenderer('maplibre-gl', ''));
const terrain = renderHook(() => useWebGL2());
expect(trip.result.current).toBe('maplibre-gl');
expect(terrain.result.current).toBe(true);
act(() => {
buildGlMap(() => {
throw gpuError();
});
});
expect(trip.result.current).toBeNull();
expect(terrain.result.current).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { useSyncExternalStore } from 'react';
import { hasWebGL, reportWebGLFailure, subscribeWebGL, webGLFailures } from '../../utils/webgl';
import type { GlMapProvider } from './glProviders';
/*
* Whether a map may be drawn with a GL engine at all, for every place that would
* build one: the trip map, the journey maps, the settings preview and the 3D tour.
*
* MapLibre 6 and Mapbox GL 3 both need WebGL2 and throw out of their Map
* constructor without it. A browser that cannot give it gets the Leaflet map with
* the user's tiles instead, never a map that fails to start. Like the GL
* renderers it guards, this module stays free of both SDKs (`check:gl-split`).
*/
/**
* The GL engine to draw with, or null for Leaflet: the user's provider, as long
* as it can run here. Mapbox also needs a token, MapLibre does not.
*/
export function pickGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined,
webgl2: () => boolean = hasWebGL
): GlMapProvider | null {
const wanted: GlMapProvider | null =
provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && mapboxToken ? 'mapbox-gl' : null;
// Asked only for a GL provider: a Leaflet user never pays for the probe's context.
return wanted && webgl2() ? wanted : null;
}
/** Re-renders the caller when a GL map found out that WebGL2 does not work after all. */
function useWebGLFailures(): number {
return useSyncExternalStore(subscribeWebGL, webGLFailures, webGLFailures);
}
/** pickGlRenderer for a component, switching to Leaflet the moment a GL map fails to build. */
export function useGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined
): GlMapProvider | null {
useWebGLFailures();
return pickGlRenderer(provider, mapboxToken);
}
/** Whether 3D (the GL terrain map) can run here, kept current the same way. */
export function useWebGL2(): boolean {
useWebGLFailures();
return hasWebGL();
}
/**
* The GPU refusing a context, as either engine reports it from its Map
* constructor. Matched on the name and message because the class itself lives in
* the maplibre chunk, and Mapbox throws a plain Error.
*/
export function isGpuInitFailure(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return err.name === 'GPUInitializationError' || /failed to initialize webgl/i.test(err.message);
}
/**
* Builds a GL map, the second line of defence behind the probe: a GPU that
* claims WebGL2 and still fails is reported, so every map switches to Leaflet,
* and the caller gets null instead of a throw. Anything else is a bug and is
* thrown on.
*/
export function buildGlMap<T>(construct: () => T): T | null {
try {
return construct();
} catch (err) {
if (!isGpuInitFailure(err)) throw err;
reportWebGLFailure();
return null;
}
}
+10 -8
View File
@@ -7,16 +7,18 @@
* this because their labels are drawn server-side, which is why it only surfaced
* once the default basemap became a vector style.
*
* Both engines expose the fix as an optional plugin and neither loads it by
* default. Registering is global to the engine, so it happens once per engine
* module rather than at each map's construction site.
* Mapbox GL exposes the fix as an optional plugin and does not load it by
* default. Registering is global to the engine, so it happens once in the engine
* module rather than at each map's construction site. MapLibre needed the same
* plugin up to version 5; since 6.9 it shapes and reorders the text itself, and
* its engine module registers nothing.
*
* The call itself stays with the caller: this is one of the places the two engines
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
* module passes its own invocation rather than a shared wrapper guessing an arity.
* The call itself stays with the caller: Mapbox GL 3 takes
* `(url, callback, deferred)` and returns nothing, while MapLibre took
* `(url, lazy)` and returned a promise, so the guard below accepts either rather
* than guessing an arity.
*
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
* Deliberately no getRTLTextPluginStatus() pre-check: reading it made MapLibre
* build its worker pool, which is an eager side effect for a module that is
* imported for its default export, and throws outright under jsdom. Registering
* twice is reported — by a throw or a rejected promise depending on the engine —
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn(), reportWebGLFailure: vi.fn() }))
const { hasWebGL } = await import('../../utils/webgl')
const { renderTripMapImage } = await import('./tripMapImage')
+11 -6
View File
@@ -1,6 +1,7 @@
import { buildRouteOverlay, wrapSvg } from './tripMapSvg'
import { hasWebGL } from '../../utils/webgl'
import { normalizeStyleForProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
import { importChunk } from '../../utils/chunkReload'
@@ -49,18 +50,20 @@ export async function renderTripMapImage(
try {
const gl = (await importChunk(() => import('../Map/engines/maplibre'))).default
container = document.createElement('div')
// A const of its own: the map is built inside a closure, where the let's narrowing is gone.
const box = document.createElement('div')
container = box
// Off-screen rather than hidden: MapLibre needs a laid-out box with real
// dimensions, and `display:none` gives it a zero-sized canvas.
container.style.cssText =
box.style.cssText =
`position:fixed;left:-10000px;top:0;width:${width}px;height:${height}px;pointer-events:none;`
document.body.appendChild(container)
document.body.appendChild(box)
const bounds = new gl.LngLatBounds()
for (const [lat, lng] of points) bounds.extend([lng, lat])
const instance = new gl.Map({
container,
const instance = buildGlMap(() => new gl.Map({
container: box,
style: normalizeStyleForProvider('maplibre-gl', opts.style),
bounds,
fitBoundsOptions: { padding: 42, animate: false },
@@ -70,7 +73,9 @@ export async function renderTripMapImage(
// MapLibre 5 moved the WebGL flags off the top level into this bag.
canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true },
fadeDuration: 0,
})
}))
// A GPU that offered WebGL2 and then refused it: reported, so the app's maps follow.
if (!instance) return null
map = instance
const ready = await new Promise<boolean>(resolve => {
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import AccountDemoLock from './AccountDemoLock';
beforeEach(() => resetAllStores());
function renderCards() {
return render(
<AccountDemoLock>
<button type="button">Save profile</button>
</AccountDemoLock>
);
}
describe('AccountDemoLock', () => {
it('FE-COMP-ACCOUNTDEMOLOCK-001: the shared demo account sees its cards under the lock, with the reason', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
const { container } = renderCards();
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
expect(screen.queryByRole('button', { name: 'Save profile' })).not.toBeInTheDocument();
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
screen.getByRole('button', { hidden: true, name: 'Save profile' })
);
});
it('FE-COMP-ACCOUNTDEMOLOCK-002: the admin of a demo instance gets the cards as they are', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
const { container } = renderCards();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
expect(container.querySelector('[data-demo-lock]')).toBeNull();
});
it('FE-COMP-ACCOUNTDEMOLOCK-003: the demo address on an instance that is not a demo is left alone', () => {
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
renderCards();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
});
});
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import DemoLock from '../Demo/DemoLock';
import { useDemoAccountLock } from '../Demo/useDemoLock';
/**
* The account cards under the demo lock, on the desktop tab and the phone
* screen alike. Every visitor of a demo instance signs in as the one shared
* demo account, so its profile, password, two factor setup and passkeys stay
* on view but out of reach, while the admin of a demo instance and every
* account of a real one get the cards as they are. Both views wrap their
* cards in this, so whom the lock holds and the reason it gives are written
* once.
*/
export default function AccountDemoLock({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const locked = useDemoAccountLock();
return (
<DemoLock locked={locked} reason={t('demo.lock.account')}>
{children}
</DemoLock>
);
}
@@ -395,10 +395,20 @@ describe('AccountTab – MFA', () => {
expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument();
});
it('FE-COMP-ACCOUNT-036: MFA section shows demoBlocked message in demo mode', () => {
seedStore(useAuthStore, { demoMode: true });
it('FE-COMP-ACCOUNT-036: the shared demo account sees the whole tab under the demo lock', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
const { container } = render(<AccountTab />);
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
screen.getByRole('button', { hidden: true, name: /Set up authenticator/ }),
);
});
it('FE-COMP-ACCOUNT-036b: the admin of a demo instance keeps the tab unlocked, 2FA included', () => {
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin', mfa_enabled: false }) });
render(<AccountTab />);
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Set up authenticator/ })).toBeInTheDocument();
});
});
@@ -12,6 +12,7 @@ import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
} from './settingsKit'
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
import AccountDemoLock from './AccountDemoLock'
/** The small turning ring a button shows while its request is on the way. */
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
@@ -71,6 +72,8 @@ export default function AccountTab(): React.ReactElement {
return (
<>
{/* The shared demo account: everything here is shown, nothing can be changed. */}
<AccountDemoLock>
<Section title={t('settings.account')} icon={User}>
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
@@ -221,10 +224,7 @@ export default function AccountTab(): React.ReactElement {
</div>
)}
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
{demoMode ? (
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<>
<SettingRows>
<SettingRow
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
@@ -357,12 +357,12 @@ export default function AccountTab(): React.ReactElement {
</div>
</div>
)}
</>
)}
</>
</Section>
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
</AccountDemoLock>
{/* Delete Account Blocked */}
<DialogShell
@@ -483,3 +483,18 @@ describe('DisplaySettingsTab week start (#2029)', () => {
expect(await screen.findByText('Save failed')).toBeInTheDocument();
});
});
describe('DisplaySettingsTab – the demo account', () => {
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), isLoaded: true });
render(<DisplaySettingsTab />);
expect(screen.getByRole('note')).toHaveTextContent('The demo always runs in English.');
});
it('FE-COMP-DISPLAY-DEMO-002: any other account of a demo instance picks its language freely', () => {
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
render(<DisplaySettingsTab />);
expect(screen.queryByRole('note')).not.toBeInTheDocument();
});
});
@@ -18,6 +18,8 @@ import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDes
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
const SELECT_BOX = 'w-[260px] max-w-full'
@@ -25,6 +27,7 @@ const SELECT_BOX = 'w-[260px] max-w-full'
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, save } = useSettingSaver()
const { t, locale } = useTranslation()
const demoLocked = useDemoAccountLock()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
const [langOpen, setLangOpen] = useState(false)
@@ -123,7 +126,9 @@ export default function DisplaySettingsTab(): React.ReactElement {
}
/>
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
The shared demo account always runs in English. */}
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
<SettingRow label={t('settings.language')} stacked>
<div className="hidden sm:block">
<ChoiceChips
@@ -162,6 +167,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
)}
</div>
</SettingRow>
</DemoLock>
{/* Place names (#1799): searches can answer in another language than the app. */}
<SettingRow
@@ -18,10 +18,13 @@ import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, Setting
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
import { useIntegrationGates } from './useIntegrationGates'
import WandererConnectionSection from './WandererConnectionSection'
import DemoLock from '../Demo/DemoLock'
import { useDemoAccountLock } from '../Demo/useDemoLock'
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
const managed = S.managed
const demoLocked = useDemoAccountLock()
return (
<>
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
@@ -42,9 +45,15 @@ export default function IntegrationsTab(): React.ReactElement {
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
one should not have to read past a section about AI assistants. */}
<ApiKeysSection />
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
<ApiKeysSection />
</DemoLock>
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
{S.mcpEnabled && (
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
<IntegrationsMcpSection S={S} />
</DemoLock>
)}
<McpTokenModals S={S} />
<OAuthClientModals S={S} />
</>
@@ -33,6 +33,12 @@ vi.mock('./MapboxPreview', () => ({
),
}));
// jsdom has no WebGL2; these cases are about a browser that has it.
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => true,
}));
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
@@ -132,7 +132,7 @@ export default function MapSettingsTab(): React.ReactElement {
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, save: saveMapSettings,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
// One chunk per engine — see components/Map/glLazy.tsx.
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
@@ -318,7 +318,7 @@ export default function MapSettingsTab(): React.ReactElement {
)}
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
{provider !== 'leaflet' ? (
{glPreview ? (
/* A net of its own: the preview is the one place a user flips providers
live, so it is the likeliest chunk to fail — and a broken preview must
not take the rest of the settings tab with it. */
@@ -363,6 +363,7 @@ export default function MapSettingsTab(): React.ReactElement {
})
)}
</div>
{webgl2Missing && <SettingsHint>{t('map.webgl2.fallback')}</SettingsHint>}
<div className="flex justify-end">
<button type="button"
@@ -32,7 +32,7 @@ const mapboxModule = vi.hoisted(() => ({ accessToken: '', Map: mapboxCtor }));
vi.mock('mapbox-gl', () => ({ default: mapboxModule }));
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
vi.mock('maplibre-gl', () => ({ default: { Map: maplibreCtor } }));
vi.mock('maplibre-gl', () => ({ Map: maplibreCtor }));
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('../Map/mapboxSetup', () => ({
@@ -45,7 +45,7 @@ vi.mock('../Map/mapboxSetup', () => ({
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup';
import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders';
import mapboxgl from 'mapbox-gl';
import maplibregl from 'maplibre-gl';
import * as maplibregl from 'maplibre-gl';
import GlMapPreviewWithEngine from './MapboxPreview';
// The engine is a prop now, not a module import — that is what keeps mapbox-gl and
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
import { buildGlMap } from '../Map/glSupport'
import { fs } from '../shared/DialogShell'
interface Props {
@@ -46,11 +47,13 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
antialias: quality,
}
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: the settings form has switched the preview to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
map.on('load', () => {
@@ -140,4 +140,13 @@ describe('AiConnectionSection', () => {
const { container } = render(<AiConnectionSection />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
});
it('FE-COMP-AI-CONNECTION-008: the shared demo account sees its own connection locked, and no admin hint', async () => {
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
render(<AiConnectionSection />);
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
expect(screen.getByRole('button', { hidden: true, name: 'Connect your own provider' })).toBeDisabled();
});
});
@@ -3,6 +3,8 @@ import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
import DemoLock from '../../Demo/DemoLock';
import { useDemoAccountLock } from '../../Demo/useDemoLock';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { fs } from '../../shared/DialogShell';
@@ -202,6 +204,25 @@ function AssistantBehaviour() {
);
}
/** Where the shared demo account would connect a provider of its own: shown, and locked. */
function LockedOwnConnection() {
const { t } = useTranslation();
return (
<DemoLock locked reason={t('demo.lock.ai')} size="row">
<SettingRows>
<SettingRow
label={t('demo.lock.aiTitle')}
control={
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
{t('assistant.errorAction.connectOwn')}
</button>
}
/>
</SettingRows>
</DemoLock>
);
}
/** The connect panel, opened on purpose or because nothing is connected yet. */
function ConnectPanel({ S }: { S: AiConnections }) {
const { t } = useTranslation();
@@ -254,6 +275,7 @@ export default function AiConnectionSection({
}): React.ReactElement | null {
const { t } = useTranslation();
const S = useAiConnections();
const demoLocked = useDemoAccountLock();
const text = aiSectionText(assistant, parsing);
if (S.view === 'hidden') return null;
const own = S.own;
@@ -277,7 +299,8 @@ export default function AiConnectionSection({
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
{S.view === 'instance' && <InstanceConnection S={S} />}
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{S.view === 'adminOnly' && !demoLocked && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{demoLocked && S.view !== 'loading' && <LockedOwnConnection />}
{S.panel && <ConnectPanel S={S} />}
<SectionFooter S={S} />
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { Place } from '../../types';
import { normalizeStyleForProvider } from '../Map/glProviders';
import { useWebGL2 } from '../Map/glSupport';
import { useToast } from '../shared/Toast';
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
@@ -51,6 +52,11 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
}, [settings]);
const previewPlaces = useMemo((): Place[] => [previewPlace()], []);
// Both GL engines need WebGL2. Without it the preview shows the Leaflet map the
// trip planner falls back to, and both shells say why.
const webgl2 = useWebGL2();
const glPreview = provider !== 'leaflet' && webgl2;
const webgl2Missing = provider !== 'leaflet' && !webgl2;
const save = async (): Promise<void> => {
setSaving(true);
@@ -105,6 +111,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
mapboxQuality,
setMapboxQuality,
previewPlaces,
glPreview,
webgl2Missing,
save,
};
}
@@ -1,6 +1,7 @@
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
import { buildGlMap } from '../../Map/glSupport';
import { isTourLayerHit } from '../../Map/mapRasterOverlay';
import {
addTerrainLayers,
@@ -110,8 +111,11 @@ export function useTerrainMap({ scene, baseLayer, data, exaggeration }: Options)
let map: MapLibreMap | null = null;
void import('../../Map/engines/maplibre').then(({ default: engine }) => {
if (cancelled) return;
map = createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle });
setGl({ map, engine });
// Null when the GPU refused WebGL2. The report switches the planner back to the 2D map.
map = buildGlMap(() =>
createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle })
);
if (map) setGl({ map, engine });
});
return () => {
cancelled = true;
@@ -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>
)
}
@@ -331,7 +331,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
{hasReply && (
<div className="mb-1 rounded-[10px] bg-[color:var(--m-card)] px-[10px] py-[5px]">
<div className="font-geist text-[0.625rem] font-bold opacity-70">{msg.reply_username}</div>
<div className="truncate text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
<div className="line-clamp-2 text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
</div>
)}
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import DemoBanner from '../components/Layout/DemoBanner'
import DemoWelcome from '../components/Demo/DemoWelcome'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
@@ -138,7 +138,7 @@ function DashboardPageDesktop(): React.ReactElement {
<Navbar />
<HelpAnchor id="dashboard" />
<div className="trek-dash trek-dash-shell">
{demoMode && <DemoBanner />}
{demoMode && <DemoWelcome />}
<div className="trek-dash-scroll">
<MobileTopBar />
<main className="page" data-no-sidebar={sidebarVisible ? undefined : 'true'}>
@@ -5,11 +5,13 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/
import { idleLegVariants, idleRoundHikes } from '../../../tests/helpers/tourPlannerStub';
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
import type { MapViewProps } from '../../components/Map/mapViewContract';
import { ToastContainer } from '../../components/shared/Toast';
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTourMapViewportStore } from '../../store/tourMapViewportStore';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
type Handler = (event?: unknown) => void;
@@ -75,6 +77,12 @@ const stage = vi.hoisted(() => {
});
vi.mock('../../components/Map/engines/maplibre', () => ({ default: stage.engine }));
// jsdom has no WebGL2: the stage is told it is there, except where a case takes it away.
const webgl = vi.hoisted(() => ({ available: true }));
vi.mock('../../utils/webgl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/webgl')>()),
hasWebGL: () => webgl.available,
}));
vi.mock('../../components/Map/MapViewAuto', () => ({
MapViewAuto: (props: MapViewProps) => {
stage.flatMap.props = props;
@@ -185,6 +193,8 @@ async function openTerrain() {
}
beforeEach(() => {
webgl.available = true;
resetWebGLProbe();
window.sessionStorage.clear();
useTourPlaceToolStore.setState(initialTourPlaceToolState());
stage.listeners.clear();
@@ -928,3 +938,44 @@ describe('TourPlannerMapStage', () => {
});
});
});
describe('3D without WebGL2', () => {
// The stage translates with the t it is handed, and stageProps hands it one that returns the key.
const NO_3D = 'map.webgl2.terrain';
function renderWithToasts(tourPlanner: Planner) {
return render(
<>
<ToastContainer />
<TourPlannerMapStage {...stageProps(tourPlanner, true)} />
</>
);
}
it('FE-PAGE-TOURSTAGE-WEBGL-001: the 3D switch keeps the 2D map and says why', async () => {
webgl.available = false;
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).not.toHaveBeenCalled();
});
it('FE-PAGE-TOURSTAGE-WEBGL-002: a GPU that refuses the terrain context brings the 2D map back', async () => {
stage.engine.Map.mockImplementationOnce(function () {
webgl.available = false;
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
throw err;
});
renderWithToasts(planner('edit-saved'));
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'flat map' })).toBeInTheDocument();
expect(stage.engine.Map).toHaveBeenCalledTimes(1);
});
});
@@ -5,11 +5,13 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { useWebGL2 } from '../../components/Map/glSupport';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
import LocationSelect from '../../components/Planner/LocationSelect';
import { useToast } from '../../components/shared/Toast';
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
@@ -25,6 +27,7 @@ import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddle
import { useSettingsStore } from '../../store/settingsStore';
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
import { publishTourMapViewport } from '../../store/tourMapViewportStore';
import { subscribeWebGL } from '../../utils/webgl';
import { useFerrataAdopt } from './useFerrataAdopt';
import { useOneCornerCard } from './useOneCornerCard';
import { useTourMapPoints } from './useTourMapPoints';
@@ -112,7 +115,22 @@ export function TourPlannerMapStage({
paddleChecklist,
assistantRoutes,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
const [terrainAsked, setTerrain3d] = useState(false);
// The terrain is a MapLibre map, so it needs WebGL2. Without it, or once its GPU
// refused the context, the stage stays on the 2D map and says why.
const webgl2 = useWebGL2();
const terrain3d = terrainAsked && webgl2;
const toast = useToast();
const openTerrain = useCallback(() => {
if (webgl2) setTerrain3d(true);
else toast.info(t('map.webgl2.terrain'));
}, [webgl2, toast, t]);
// A GPU that offered WebGL2 and then refused the terrain's context: the 2D map is
// back by itself, and this says why.
useEffect(() => {
if (!terrainAsked) return;
return subscribeWebGL(() => toast.info(t('map.webgl2.terrain')));
}, [terrainAsked, toast, t]);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
const [terrainLegend, setTerrainLegend] = useState<TerrainLegendLayout | null>(null);
@@ -287,7 +305,7 @@ export function TourPlannerMapStage({
viewBaseLayer={baseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
tourLayers={tourLayers}
onToggleTerrain3d={() => setTerrain3d(true)}
onToggleTerrain3d={openTerrain}
ferrataLayer={ferrataLayer}
onViewportChange={viewportChanged}
placeTool={placeTool}
+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);
+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({
+48 -1
View File
@@ -1,5 +1,7 @@
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. */
export const DEMO_WRITE_ERROR = {
@@ -29,3 +31,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)));
}
+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' };
@@ -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 } : {}) };
@@ -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');
});
});
@@ -0,0 +1,174 @@
/**
* The demo addon policy: which addons, providers and addon switches a demo
* instance runs with, and that the seed and the hourly reset both put them back.
*
* The lists are explicit on purpose, so a release that adds an addon has to say
* whether a demo shows it: DEMOPOL-001 fails on a seeded row that is in neither
* list, and on a listed id the database does not hold.
*/
import { Addons } from '../../../src/db/entities/Addons.entity';
import { DocumentProviders } from '../../../src/db/entities/DocumentProviders.entity';
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { DEMO_ADDON_POLICY } from '../../../src/demo/demo-addon-policy';
import { resetDemoUser } from '../../../src/demo/demo-reset';
import { seedDemoData } from '../../../src/demo/demo-seed';
import type { DatabaseBackupStrategy } from '../../../src/nest/database/database-backup.interface';
import { withRequestContext } from '../../../src/nest/database/request-context';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { findRow, findRows, updateRows } from '../../helpers/factories/rows';
import { readAppSetting, readUserSetting, setAppSetting, setUserSetting } from '../../helpers/factories/settings';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import type Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const BASELINE = path.resolve(__dirname, '..', '..', '..', 'data', 'travel-baseline.db');
let db: Database.Database;
let t: TestOrm;
beforeEach(async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
db = createSnapshotTestDb();
t = await createTestOrm(db);
});
afterEach(async () => {
vi.restoreAllMocks();
await t.close();
db.close();
});
const seed = () => withRequestContext(t.orm, () => seedDemoData());
async function enabledIds(entity: typeof Addons | typeof PhotoProviders | typeof DocumentProviders) {
const rows = (await findRows(t, entity as typeof Addons)) as { id: string; enabled: unknown }[];
return {
on: rows
.filter((r) => !!r.enabled)
.map((r) => r.id)
.sort(),
off: rows
.filter((r) => !r.enabled)
.map((r) => r.id)
.sort(),
};
}
const sorted = (ids: readonly string[]) => [...ids].sort();
/** What an admin of a demo instance might have switched during the hour. */
async function scramble(): Promise<void> {
await updateRows(t, Addons, { id: 'airtrail' }, { enabled: true });
await updateRows(t, Addons, { id: 'packing' }, { enabled: false });
await updateRows(t, Addons, { id: 'journey' }, { enabled: false });
await updateRows(t, PhotoProviders, { id: 'immich' }, { enabled: 1 });
await updateRows(t, DocumentProviders, { id: 'paperless' }, { enabled: 1 });
await setAppSetting(t, 'tours_wanderer_enabled', 'true');
await setAppSetting(t, 'collab_chat_enabled', 'false');
}
async function expectPolicyApplied(): Promise<void> {
expect(await enabledIds(Addons)).toEqual({
on: sorted(DEMO_ADDON_POLICY.addons.on),
off: sorted(DEMO_ADDON_POLICY.addons.off),
});
expect(await enabledIds(PhotoProviders)).toEqual({ on: [], off: sorted(DEMO_ADDON_POLICY.photoProviders.off) });
expect(await enabledIds(DocumentProviders)).toEqual({
on: [],
off: sorted(DEMO_ADDON_POLICY.documentProviders.off),
});
const keys = Object.keys(DEMO_ADDON_POLICY.appSettings);
const values = await Promise.all(keys.map((key) => readAppSetting(t, key)));
expect(Object.fromEntries(keys.map((key, i) => [key, values[i]]))).toEqual(DEMO_ADDON_POLICY.appSettings);
}
describe('demo addon policy', () => {
it('DEMOPOL-001: names every seeded addon and provider exactly once, and nothing the database lacks', async () => {
const shelves = [
[Addons, DEMO_ADDON_POLICY.addons],
[PhotoProviders, DEMO_ADDON_POLICY.photoProviders],
[DocumentProviders, DEMO_ADDON_POLICY.documentProviders],
] as const;
const seeded = await Promise.all(shelves.map(([entity]) => findRows(t, entity as typeof Addons)));
shelves.forEach(([, shelf], i) => {
const listed = [...shelf.on, ...shelf.off];
expect(new Set(listed).size).toBe(listed.length);
expect(sorted(listed)).toEqual(seeded[i].map((r) => r.id).sort());
});
});
it('DEMOPOL-002: keeps off exactly what reaches a server of the visitor’s own, the AI features, MCP and the legacy Photos row', () => {
expect(sorted(DEMO_ADDON_POLICY.addons.off)).toEqual([
'ai_assistant',
'airtrail',
'dawarich',
'llm_parsing',
'mcp',
'memories',
]);
expect(sorted(DEMO_ADDON_POLICY.photoProviders.off)).toEqual(['immich', 'synologyphotos']);
expect(sorted(DEMO_ADDON_POLICY.documentProviders.off)).toEqual(
sorted(['paperless', 'papra', 'nextcloud', 'opencloud', 'synologydrive']),
);
expect(DEMO_ADDON_POLICY.appSettings.tours_wanderer_enabled).toBe('false');
const otherSwitches = Object.entries(DEMO_ADDON_POLICY.appSettings).filter(
([key]) => key !== 'tours_wanderer_enabled',
);
expect(otherSwitches.every(([, value]) => value === 'true')).toBe(true);
});
it('DEMOPOL-003: the first demo boot puts the whole shelf to the demo set', async () => {
await scramble();
await seed();
await expectPolicyApplied();
});
it('DEMOPOL-004: a later boot, with the example trips already there, puts it back again', async () => {
await seed();
await scramble();
await seed();
await expectPolicyApplied();
});
it('DEMOPOL-005: the demo account runs in English, whatever a visitor picked before', async () => {
const { demoId } = await seed();
await setUserSetting(t, demoId, 'language', 'de');
await seed();
expect(await readUserSetting(t, demoId, 'language')).toBe('en');
});
it('DEMOPOL-006: the admin account keeps the language it chose', async () => {
const { adminId } = await seed();
await setUserSetting(t, adminId, 'language', 'de');
await seed();
expect(await readUserSetting(t, adminId, 'language')).toBe('de');
});
});
describe('demo addon policy across the hourly reset', () => {
it('DEMOPOL-007: the reset puts the demo set back after the baseline swap, whatever the baseline held', async () => {
await seed();
vi.spyOn(fs, 'existsSync').mockImplementation(((p: fs.PathLike) => String(p) === BASELINE) as typeof fs.existsSync);
// The stand-in swap brings back a baseline whose shelf an admin had changed.
const database = {
canSnapshot: () => true,
checkpoint: vi.fn(async () => undefined),
replace: vi.fn(async () => {
await scramble();
return { reopenError: undefined };
}),
} as unknown as DatabaseBackupStrategy;
await withRequestContext(t.orm, () => resetDemoUser(database));
t.clear();
expect(database.replace).toHaveBeenCalled();
await expectPolicyApplied();
expect(await findRow(t, Users, { email: 'demo@trek.app' })).not.toBeNull();
});
});
+25 -1
View File
@@ -27,7 +27,7 @@ import { takeExampleTripsSeeded } from '../../../src/demo/demo-reset';
import { seedDemoData } from '../../../src/demo/demo-seed';
import { withRequestContext } from '../../../src/nest/database/request-context';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { countRows, findRows } from '../../helpers/factories/rows';
import { countRows, deleteRows, findRows, updateRows } from '../../helpers/factories/rows';
import { readAppSetting } from '../../helpers/factories/settings';
import { readUser } from '../../helpers/factories/users';
import { createTestOrm } from '../../helpers/test-orm';
@@ -167,4 +167,28 @@ describe('demo seeding', () => {
await seed();
expect(takeExampleTripsSeeded()).toBe(false);
});
it('DEMOSEED-006: leaves a demo alone whose trips belong to the demo account, not the admin', async () => {
takeExampleTripsSeeded();
const { adminId, demoId } = await seed();
expect(takeExampleTripsSeeded()).toBe(true);
// The demo content is rebuilt as the demo user's own: the admin's example
// trips go, one trip owned by the demo account stays.
const orm = await createTestOrm(db);
const exampleTrips = await findRows(orm, Trips, { user_id: adminId }, { id: 'asc' });
const keep = exampleTrips[0];
await deleteRows(orm, Trips, { id: { $in: exampleTrips.slice(1).map((t) => t.id) } });
await updateRows(orm, Trips, { id: keep.id }, { user: demoId });
await orm.close();
await seed();
const after = await createTestOrm(db);
const trips = await findRows(after, Trips, {}, { id: 'asc' });
await after.close();
expect(trips.map((t) => ({ id: t.id, user_id: t.user_id }))).toEqual([{ id: keep.id, user_id: demoId }]);
// Nothing was seeded, so no baseline is due either.
expect(takeExampleTripsSeeded()).toBe(false);
});
});
+61 -2
View File
@@ -12,11 +12,23 @@
*/
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { isDemoEmail } from '../../../src/nest/common/demo';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../../../src/nest/common/demo-write';
import {
DEMO_ACCOUNT_REFUSAL,
DEMO_WRITE_ERROR,
isDemoAccount,
isDemoLockedSetting,
isDemoWriteBlocked,
refuseForDemoAccount,
withoutDemoLockedSettings,
} from '../../../src/nest/common/demo-write';
import { DomainError } from '../../../src/nest/common/domain-error';
import { describe, it, expect, vi, afterEach } from 'vitest';
vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
vi.mock('../../../src/nest/common/demo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/common/demo')>()),
isDemoEmail: vi.fn(() => false),
}));
const env = new RuntimeEnvService();
@@ -60,3 +72,50 @@ describe('isDemoWriteBlocked', () => {
});
});
});
describe('the demo account locks', () => {
const demo = { email: 'demo@trek.app' };
const other = { email: 'alice@example.com' };
const onDemoInstance = () => {
process.env.DEMO_MODE = 'true';
vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app');
};
it('DEMO-WRITE-006: isDemoAccount is the demo address on a demo instance, and nothing else', () => {
vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app');
expect(isDemoAccount(demo)).toBe(false);
onDemoInstance();
expect(isDemoAccount(demo)).toBe(true);
expect(isDemoAccount(other)).toBe(false);
expect(isDemoAccount(null)).toBe(false);
});
it('DEMO-WRITE-007: refuseForDemoAccount throws a 403 with the message for the demo account only', () => {
onDemoInstance();
expect(() => refuseForDemoAccount(other, DEMO_ACCOUNT_REFUSAL.apiKeys)).not.toThrow();
let thrown: unknown;
try {
refuseForDemoAccount(demo, DEMO_ACCOUNT_REFUSAL.apiKeys);
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(DomainError);
expect((thrown as DomainError).getStatus()).toBe(403);
expect((thrown as DomainError).getResponse()).toEqual({ error: 'API keys cannot be created in demo mode.' });
});
it('DEMO-WRITE-008: the language is the setting the demo account keeps', () => {
onDemoInstance();
expect(isDemoLockedSetting(demo, 'language')).toBe(true);
expect(isDemoLockedSetting(demo, 'temperature_unit')).toBe(false);
expect(isDemoLockedSetting(other, 'language')).toBe(false);
});
it('DEMO-WRITE-009: a bulk save of the demo account loses the language and keeps the rest', () => {
const settings = { language: 'de', distance_unit: 'imperial' };
expect(withoutDemoLockedSettings(demo, settings)).toBe(settings);
onDemoInstance();
expect(withoutDemoLockedSettings(demo, settings)).toEqual({ distance_unit: 'imperial' });
expect(withoutDemoLockedSettings(other, settings)).toBe(settings);
});
});
@@ -0,0 +1,129 @@
/**
* The OAuth half of the MCP lock on a demo instance: the shared demo account
* cannot register, rotate or delete an OAuth client, end a connected app, or
* approve an app's request for access. Turning a request down stays possible.
* Every refusal comes before the service is asked, so nothing is written.
*
* The controller comes out of OauthModule with OauthService replaced by the
* stand-in below, so it is wired the way the app wires it.
*/
import { DEMO_ACCOUNT_REFUSAL } from '../../../src/nest/common/demo-write';
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
import { OauthModule } from '../../../src/nest/oauth/oauth.module';
import { OauthService } from '../../../src/nest/oauth/oauth.service';
import type { User } from '../../../src/types';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
const demo = { id: 1, role: 'user', email: 'demo@trek.app' } as User;
const other = { id: 2, role: 'user', email: 'alice@example.test' } as User;
const req = { ip: '1.2.3.4', headers: {} } as unknown as Request;
function service() {
return {
mcpEnabled: vi.fn().mockResolvedValue(true),
validateAuthorizeRequest: vi.fn().mockResolvedValue({ valid: true, scopes: ['trips:read'], resource: null }),
saveConsent: vi.fn(),
createAuthCode: vi.fn().mockResolvedValue('the_code'),
createOAuthClient: vi.fn().mockResolvedValue({ client_id: 'c1', client_secret: 's' }),
rotateOAuthClientSecret: vi.fn().mockResolvedValue({ client_secret: 's2' }),
deleteOAuthClient: vi.fn().mockResolvedValue({}),
revokeSession: vi.fn().mockResolvedValue({}),
};
}
const db = createSnapshotTestDb();
// The module hands the controller this very object, so each test re-arms its
// mocks in place instead of building a new container.
const svc = service();
let t: TestModule;
let api: OauthApiController;
beforeAll(async () => {
t = await createTestModule({ db, imports: [OauthModule], overrides: [{ provide: OauthService, useValue: svc }] });
api = t.get(OauthApiController);
});
afterAll(async () => {
await t.close();
db.close();
});
async function refusal(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected a refusal');
}
const consent = (approved: boolean) => ({
client_id: 'c1',
redirect_uri: 'https://app.example.test/cb',
scope: 'trips:read',
state: 'st',
code_challenge: 'cc',
code_challenge_method: 'S256' as const,
approved,
});
const MCP_REFUSAL = { status: 403, body: { error: DEMO_ACCOUNT_REFUSAL.mcp } };
beforeEach(() => {
vi.stubEnv('DEMO_MODE', 'true');
Object.assign(svc, service());
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('OAuth routes for the demo account', () => {
it('OAUTH-DEMO-001: registering, rotating and deleting a client are refused before the service runs', async () => {
expect(await refusal(() => api.createClient(demo, { name: 'CLI', allowed_scopes: ['a'] }, req))).toEqual(
MCP_REFUSAL,
);
expect(await refusal(() => api.rotateClient(demo, 'c1', req))).toEqual(MCP_REFUSAL);
expect(await refusal(() => api.deleteClient(demo, 'c1', req))).toEqual(MCP_REFUSAL);
expect(svc.createOAuthClient).not.toHaveBeenCalled();
expect(svc.rotateOAuthClientSecret).not.toHaveBeenCalled();
expect(svc.deleteOAuthClient).not.toHaveBeenCalled();
});
it('OAUTH-DEMO-002: a connected app cannot be ended from the demo account', async () => {
expect(await refusal(() => api.revokeSession(demo, '7', req))).toEqual(MCP_REFUSAL);
expect(svc.revokeSession).not.toHaveBeenCalled();
});
it('OAUTH-DEMO-003: approving an app is refused, turning it down still redirects', async () => {
expect(await refusal(() => api.authorize(demo, consent(true), req))).toEqual(MCP_REFUSAL);
expect(svc.saveConsent).not.toHaveBeenCalled();
expect(svc.createAuthCode).not.toHaveBeenCalled();
const denied = await api.authorize(demo, consent(false), req);
expect((denied as { redirect: string }).redirect).toContain('error=access_denied');
});
it('OAUTH-DEMO-004: every other account of the demo instance keeps the routes', async () => {
expect(await api.createClient(other, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({
client_id: 'c1',
client_secret: 's',
});
const approved = await api.authorize(other, consent(true), req);
expect((approved as { redirect: string }).redirect).toContain('code=the_code');
});
it('OAUTH-DEMO-005: off a demo instance the demo address keeps them too', async () => {
vi.stubEnv('DEMO_MODE', 'false');
expect(await api.deleteClient(demo, 'c1', req)).toEqual({ success: true });
});
});
+19
View File
@@ -0,0 +1,19 @@
import { DEMO_ACCOUNT_EMAILS, isDemoAccountEmail } from './demo-account';
import { describe, expect, it } from 'vitest';
describe('isDemoAccountEmail', () => {
it('knows the current and the pre-rename demo address', () => {
expect(DEMO_ACCOUNT_EMAILS).toEqual(['demo@trek.app', 'demo@nomad.app']);
expect(isDemoAccountEmail('demo@trek.app')).toBe(true);
expect(isDemoAccountEmail('demo@nomad.app')).toBe(true);
});
it('is false for every other address and for none at all', () => {
expect(isDemoAccountEmail('admin@trek.app')).toBe(false);
expect(isDemoAccountEmail('DEMO@trek.app')).toBe(false);
expect(isDemoAccountEmail('')).toBe(false);
expect(isDemoAccountEmail(null)).toBe(false);
expect(isDemoAccountEmail(undefined)).toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* The shared account every visitor of a demo instance signs in as.
*
* Both sides ask the same question: the server refuses the writes this
* account may not make, and the client shows the same sections under a demo
* lock. `demo@nomad.app` is the address the account had before the rename, so
* an instance upgraded in place still treats it as the demo account.
*/
export const DEMO_ACCOUNT_EMAILS: readonly string[] = ['demo@trek.app', 'demo@nomad.app'];
/** Whether this address is the demo account's. Instance demo mode is asked apart. */
export function isDemoAccountEmail(email: string | null | undefined): boolean {
return !!email && DEMO_ACCOUNT_EMAILS.includes(email);
}
+65
View File
@@ -0,0 +1,65 @@
import type { TranslationStrings } from '../types';
const demo: TranslationStrings = {
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
'demo.welcome.eyebrow': 'عرض تجريبي مباشر',
'demo.welcome.title': 'مرحبًا بك في TREK',
'demo.welcome.tagline':
'مخطط الرحلات الذي تتشاركه: خططوا معًا على الخريطة في الوقت الفعلي، من أول فكرة حتى ألبوم الصور بعد الرحلة.',
'demo.welcome.resetTitle': 'يُعاد ضبط كل شيء كل ساعة',
'demo.welcome.resetBody':
'استكشف وعدّل وأنشئ كما تشاء. عند بداية كل ساعة يعود العرض التجريبي إلى حالته الأولى للزائر التالي.',
'demo.welcome.resetIn': 'إعادة الضبط التالية بعد',
'demo.welcome.tryTitle': 'جرّبه الآن',
'demo.feature.planner.title': 'مخطط الرحلات',
'demo.feature.planner.body':
'الأيام والأماكن والمسارات على الخريطة، مع الرحلات الجوية والفنادق والمطاعم والحجوزات والتكاليف المشتركة.',
'demo.feature.tours.title': 'الجولات والمشي',
'demo.feature.tours.body': 'خطط لمسار مشي على شبكة الدروب وتابعه فوق تضاريس ثلاثية الأبعاد مع مخطط الارتفاع.',
'demo.feature.studio.title': 'ألبومات الصور في TREK Studio',
'demo.feature.studio.body': 'حوّل رحلتك إلى ألبوم صور مصمم، صفحة بعد صفحة.',
'demo.feature.collab.title': 'خططوا معًا مباشرة',
'demo.feature.collab.body': 'يرى كل من في الرحلة كل تغيير لحظة حدوثه، مع الدردشة والملاحظات والاستطلاعات.',
'demo.feature.journey.title': 'مذكرات السفر Journey',
'demo.feature.journey.body': 'تدوينات وصور ومزاج وإيجابيات وسلبيات لكل يوم على الطريق.',
'demo.feature.atlas.title': 'خريطة العالم Atlas',
'demo.feature.atlas.body': 'الدول والمناطق التي زرتها، مع إحصاءات سفرك.',
'demo.feature.vacay.title': 'مخطط الإجازات Vacay',
'demo.feature.vacay.body': 'خطط لأيام إجازتك حول العطل الرسمية واعرف متى يكون الجميع متفرغين.',
'demo.feature.collections.title': 'المجموعات',
'demo.feature.collections.body': 'احفظ أماكن من رحلات مختلفة في قوائمك الخاصة وانسخها إلى أي خطة.',
'demo.feature.offline.title': 'يعمل دون اتصال',
'demo.feature.offline.body': 'ثبّت TREK كتطبيق واحتفظ بخططك وخرائطك وقوائمك دون اتصال.',
'demo.welcome.limitsTitle': 'غير متاح في العرض التجريبي',
'demo.limit.uploads': 'رفع الصور والمستندات وصور الغلاف',
'demo.limit.account': 'تغيير الحساب أو كلمة المرور أو 2FA',
'demo.limit.apiKeys': 'إنشاء مفاتيح API',
'demo.limit.mcp': 'ربط عملاء MCP',
'demo.limit.ai': 'ربط نموذج ذكاء اصطناعي خاص بك',
'demo.welcome.fullTitle': 'تضيف النسخة الكاملة',
'demo.full.sso': 'تسجيل الدخول عبر OIDC و SSO',
'demo.full.backups': 'نسخ احتياطية تلقائية',
'demo.full.plugins': 'إضافات لميزات جديدة',
'demo.full.assistant': 'مساعد الذكاء الاصطناعي بنموذجك الخاص',
'demo.full.admin': 'لوحة إدارة للمستخدمين والإضافات والصلاحيات',
'demo.welcome.repo': 'الشيفرة المصدرية على GitHub',
'demo.welcome.start': 'ابدأ الاستكشاف',
'demo.welcome.credits': 'حقوق الصور',
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
'demo.credits.title': 'حقوق الصور',
'demo.credits.intro':
'صور هذا العرض التجريبي من ويكيميديا كومنز، ولكل صورة الترخيص المذكور معها. ينطبق هذا الترخيص على الصورة وحدها، لا على TREK ولا على بقية العرض التجريبي.',
'demo.credits.by': 'تصوير {author}',
'demo.credits.publicDomain': 'ملكية عامة',
'demo.credits.back': 'رجوع',
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
'demo.lock.badge': 'الوضع التجريبي',
'demo.lock.apiKeys': 'لا يمكن إنشاء مفاتيح API في العرض التجريبي.',
'demo.lock.mcp': 'لا يمكن ربط عملاء MCP في العرض التجريبي.',
'demo.lock.ai': 'لا يمكن إضافة اتصال ذكاء اصطناعي خاص بك في العرض التجريبي.',
'demo.lock.aiTitle': 'اربط نموذج ذكاء اصطناعي خاصًا بك',
'demo.lock.account': 'لا يمكن تغيير حساب العرض التجريبي المشترك.',
'demo.lock.language': 'يعمل العرض التجريبي دائمًا بالإنجليزية.',
};
export default demo;
+2
View File
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
import dawarich from './dawarich';
import day from './day';
import dayplan from './dayplan';
import demo from './demo';
import docsync from './docsync';
import ferratas from './ferratas';
import files from './files';
@@ -118,6 +119,7 @@ const locale = {
...pdf,
...planner,
...day,
...demo,
...memories,
...collab,
...perm,
+3
View File
@@ -47,5 +47,8 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted.few': 'تعذّر حساب {count} مراحل من هذا اليوم',
'map.overview.dayUnrouted.many': 'تعذّر حساب {count} مرحلة من هذا اليوم',
'map.baseLayer.topo': 'طبوغرافية',
'map.webgl2.fallback':
'لا يوفّر هذا المتصفح WebGL2 الذي تحتاجه خرائط GL، لذا تُعرض الخرائط باستخدام Leaflet بدلًا منها.',
'map.webgl2.terrain': 'يحتاج العرض ثلاثي الأبعاد إلى WebGL2، وهذا المتصفح لا يوفّره. تبقى الخريطة ثنائية الأبعاد.',
};
export default map;
-1
View File
@@ -302,7 +302,6 @@ const settings: TranslationStrings = {
'settings.mfa.disable': 'تعطيل 2FA',
'settings.mfa.toastEnabled': 'تم تفعيل المصادقة الثنائية',
'settings.mfa.toastDisabled': 'تم تعطيل المصادقة الثنائية',
'settings.mfa.demoBlocked': 'غير متاح في الوضع التجريبي',
'settings.tabs.offline': 'دون اتصال',
'settings.notificationPreferences.email': 'البريد الإلكتروني',
'settings.notificationPreferences.webhook': 'Webhook',
+67
View File
@@ -0,0 +1,67 @@
import type { TranslationStrings } from '../types';
const demo: TranslationStrings = {
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
'demo.welcome.eyebrow': 'Canlı demo',
'demo.welcome.title': 'TREK-ə xoş gəlmisiniz',
'demo.welcome.tagline':
'Paylaşılan səyahət planlayıcısı: xəritədə real vaxtda birlikdə planlayın, ilk ideyadan səfərdən sonrakı foto kitaba qədər.',
'demo.welcome.resetTitle': 'Hər saat hər şey sıfırlanır',
'demo.welcome.resetBody':
'İstədiyiniz qədər baxın, redaktə edin və yaradın. Hər tam saatda demo növbəti ziyarətçi üçün ilkin vəziyyətinə qayıdır.',
'demo.welcome.resetIn': 'Növbəti sıfırlamaya qalıb',
'demo.welcome.tryTitle': 'İndi sınayın',
'demo.feature.planner.title': 'Səyahət planlayıcısı',
'demo.feature.planner.body':
'Xəritədə günlər, yerlər və marşrutlar; uçuşlar, otellər, restoranlar, rezervasiyalar və ortaq xərclərlə.',
'demo.feature.tours.title': 'Turlar və gəzintilər',
'demo.feature.tours.body': 'Cığır şəbəkəsində gəzinti planlayın və hündürlük profili ilə 3D relyefdə izləyin.',
'demo.feature.studio.title': 'TREK Studio ilə foto kitablar',
'demo.feature.studio.body': 'Səyahəti səhifə səhifə tərtib olunmuş foto kitaba çevirin.',
'demo.feature.collab.title': 'Canlı birlikdə planlayın',
'demo.feature.collab.body': 'Səfərdəki hər kəs hər dəyişikliyi dərhal görür; söhbət, qeydlər və sorğularla.',
'demo.feature.journey.title': 'Journey səyahət gündəliyi',
'demo.feature.journey.body': 'Yolda keçən hər gün üçün qeydlər, fotolar, əhval, üstünlüklər və çatışmazlıqlar.',
'demo.feature.atlas.title': 'Atlas dünya xəritəsi',
'demo.feature.atlas.body': 'Səyahət statistikanızla birlikdə ziyarət etdiyiniz ölkələr və bölgələr.',
'demo.feature.vacay.title': 'Vacay məzuniyyət planlayıcısı',
'demo.feature.vacay.body': 'İstirahət günlərinizi bayramlara görə planlayın və hamının nə vaxt boş olduğunu görün.',
'demo.feature.collections.title': 'Kolleksiyalar',
'demo.feature.collections.body':
'Müxtəlif səfərlərdən yerləri öz siyahılarınıza yadda saxlayın və istənilən plana köçürün.',
'demo.feature.offline.title': 'Oflayn işləyir',
'demo.feature.offline.body':
'TREK-i tətbiq kimi quraşdırın; planlar, xəritələr və siyahılar bağlantı olmadan da əlinizdə olsun.',
'demo.welcome.limitsTitle': 'Demoya daxil deyil',
'demo.limit.uploads': 'Foto, sənəd və üz qabığı yükləmək',
'demo.limit.account': 'Hesabı, parolu və ya 2FA ayarını dəyişmək',
'demo.limit.apiKeys': 'API açarları yaratmaq',
'demo.limit.mcp': 'MCP müştərilərini qoşmaq',
'demo.limit.ai': 'Öz süni intellekt modelinizi qoşmaq',
'demo.welcome.fullTitle': 'Tam versiyada əlavə olaraq',
'demo.full.sso': 'OIDC və SSO ilə giriş',
'demo.full.backups': 'Avtomatik ehtiyat nüsxələr',
'demo.full.plugins': 'Yeni imkanlar üçün plaginlər',
'demo.full.assistant': 'Öz modelinizlə süni intellekt köməkçisi',
'demo.full.admin': 'İstifadəçilər, əlavələr və icazələr üçün idarəetmə paneli',
'demo.welcome.repo': 'GitHub-da mənbə kodu',
'demo.welcome.start': 'Kəşf etməyə başlayın',
'demo.welcome.credits': 'Foto müəllifləri',
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
'demo.credits.title': 'Foto müəllifləri',
'demo.credits.intro':
'Bu demodakı fotolar Wikimedia Commons-dan götürülüb və hər biri yanında göstərilən lisenziya ilə yayımlanır. Bu lisenziya yalnız fotoya aiddir, TREK-ə və ya demonun qalan hissəsinə aid deyil.',
'demo.credits.by': 'Müəllif: {author}',
'demo.credits.publicDomain': 'İctimai mülkiyyət',
'demo.credits.back': 'Geri',
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
'demo.lock.badge': 'Demo rejimi',
'demo.lock.apiKeys': 'Demoda API açarları yaratmaq olmur.',
'demo.lock.mcp': 'Demoda MCP müştərilərini qoşmaq olmur.',
'demo.lock.ai': 'Demoda öz süni intellekt bağlantınızı əlavə etmək olmur.',
'demo.lock.aiTitle': 'Öz süni intellekt modelinizi qoşun',
'demo.lock.account': 'Ortaq demo hesabı dəyişdirilə bilməz.',
'demo.lock.language': 'Demo həmişə ingilis dilində işləyir.',
};
export default demo;
+2
View File
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
import dawarich from './dawarich';
import day from './day';
import dayplan from './dayplan';
import demo from './demo';
import docsync from './docsync';
import ferratas from './ferratas';
import files from './files';
@@ -118,6 +119,7 @@ const locale = {
...pdf,
...planner,
...day,
...demo,
...memories,
...collab,
...perm,
+3
View File
@@ -45,6 +45,9 @@ const map: TranslationStrings = {
'map.overview.dayUnrouted': 'Bu günün {count} marşrut hissəsini hesablamaq mümkün olmadı',
'map.overview.dayUnrouted.one': 'Bu günün {count} marşrut hissəsini hesablamaq mümkün olmadı',
'map.baseLayer.topo': 'Topoqrafik',
'map.webgl2.fallback':
'Bu brauzer GL xəritələrinin ehtiyac duyduğu WebGL2-ni təqdim etmir, ona görə xəritələr Leaflet ilə göstərilir.',
'map.webgl2.terrain': '3D üçün WebGL2 lazımdır, bu brauzer isə onu təqdim etmir. Xəritə 2D olaraq qalır.',
};
export default map;
-1
View File
@@ -396,7 +396,6 @@ const settings: TranslationStrings = {
'settings.mfa.disable': '2FA-nı deaktiv et',
'settings.mfa.toastEnabled': 'İki mərhələli autentifikasiya aktivləşdirildi',
'settings.mfa.toastDisabled': 'İki mərhələli autentifikasiya deaktiv edildi',
'settings.mfa.demoBlocked': 'Demo rejimində əlçatan deyil',
'settings.currency': 'Göstərilən valyuta',
'settings.currencyHint':
+66
View File
@@ -0,0 +1,66 @@
import type { TranslationStrings } from '../types';
const demo: TranslationStrings = {
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
'demo.welcome.eyebrow': 'Demo ao vivo',
'demo.welcome.title': 'Boas vindas ao TREK',
'demo.welcome.tagline':
'O planejador de viagens para compartilhar: planejem juntos no mapa em tempo real, da primeira ideia ao fotolivro depois da viagem.',
'demo.welcome.resetTitle': 'Tudo é redefinido a cada hora',
'demo.welcome.resetBody':
'Explore, edite e crie o quanto quiser. A cada hora cheia a demo volta ao estado inicial para o próximo visitante.',
'demo.welcome.resetIn': 'Próxima redefinição em',
'demo.welcome.tryTitle': 'Experimente agora mesmo',
'demo.feature.planner.title': 'Planejador de viagens',
'demo.feature.planner.body':
'Dias, lugares e rotas no mapa, com voos, hotéis, restaurantes, reservas e custos divididos.',
'demo.feature.tours.title': 'Passeios e trilhas',
'demo.feature.tours.body':
'Planeje uma trilha na rede de caminhos e siga a rota sobre o relevo em 3D com o perfil de altitude.',
'demo.feature.studio.title': 'Fotolivros com o TREK Studio',
'demo.feature.studio.body': 'Transforme uma viagem em um fotolivro diagramado, página por página.',
'demo.feature.collab.title': 'Planejar juntos ao vivo',
'demo.feature.collab.body': 'Todos na viagem veem cada mudança na hora, com chat, notas e enquetes.',
'demo.feature.journey.title': 'Diário de viagem Journey',
'demo.feature.journey.body': 'Registros, fotos, humores, prós e contras de cada dia na estrada.',
'demo.feature.atlas.title': 'Mapa mundi Atlas',
'demo.feature.atlas.body': 'Os países e regiões que você visitou, com suas estatísticas de viagem.',
'demo.feature.vacay.title': 'Planejador de férias Vacay',
'demo.feature.vacay.body': 'Planeje suas folgas em torno dos feriados e veja quando todos estão livres.',
'demo.feature.collections.title': 'Coleções',
'demo.feature.collections.body': 'Salve lugares de várias viagens em listas suas e copie para qualquer plano.',
'demo.feature.offline.title': 'Funciona offline',
'demo.feature.offline.body': 'Instale o TREK como app e mantenha planos, mapas e listas sem conexão.',
'demo.welcome.limitsTitle': 'Fora da demo',
'demo.limit.uploads': 'Enviar fotos, documentos e capas',
'demo.limit.account': 'Alterar a conta, a senha ou a 2FA',
'demo.limit.apiKeys': 'Criar chaves de API',
'demo.limit.mcp': 'Conectar clientes MCP',
'demo.limit.ai': 'Conectar seu próprio modelo de IA',
'demo.welcome.fullTitle': 'A versão completa inclui',
'demo.full.sso': 'Login com OIDC e SSO',
'demo.full.backups': 'Backups automáticos',
'demo.full.plugins': 'Plugins para novos recursos',
'demo.full.assistant': 'O assistente de IA com seu próprio modelo',
'demo.full.admin': 'Um painel de administração para usuários, complementos e permissões',
'demo.welcome.repo': 'Código fonte no GitHub',
'demo.welcome.start': 'Começar a explorar',
'demo.welcome.credits': 'Créditos das fotos',
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
'demo.credits.title': 'Créditos das fotos',
'demo.credits.intro':
'As fotos desta demo vêm do Wikimedia Commons, cada uma sob a licença indicada junto a ela. Essa licença vale apenas para a foto, não para o TREK nem para o resto da demo.',
'demo.credits.by': 'por {author}',
'demo.credits.publicDomain': 'Domínio público',
'demo.credits.back': 'Voltar',
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
'demo.lock.badge': 'Modo demonstração',
'demo.lock.apiKeys': 'Não é possível criar chaves de API na demo.',
'demo.lock.mcp': 'Não é possível conectar clientes MCP na demo.',
'demo.lock.ai': 'Não é possível adicionar uma conexão de IA própria na demo.',
'demo.lock.aiTitle': 'Conectar seu próprio modelo de IA',
'demo.lock.account': 'A conta de demonstração compartilhada não pode ser alterada.',
'demo.lock.language': 'A demo sempre funciona em inglês.',
};
export default demo;

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