mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 09:08:30 +00:00
Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35fb59e64e | |||
| ee3fff1dab | |||
| 3bc75d2b1c | |||
| 094426debb | |||
| 313198ba63 | |||
| e677d58f91 | |||
| bc60aea839 | |||
| b5cd951bd2 | |||
| fd818050bd | |||
| 0313597b10 | |||
| 2c867673bf | |||
| 39ebf0976c | |||
| 1ecc449f3e | |||
| b8990782cc | |||
| 8ba7add541 | |||
| a3664e7117 | |||
| 64ed2175a5 | |||
| 447ae54b93 | |||
| 0067b0ff19 | |||
| d589da59f2 | |||
| 69ff295184 | |||
| d07c17ded6 | |||
| 44509723fd | |||
| b80fdf27f2 | |||
| a0b7b4d196 | |||
| aa24a9eff7 | |||
| de5417f9f0 | |||
| a48dd7645f | |||
| 217a576e16 | |||
| f6976d5cec | |||
| c1c2bbf146 | |||
| c05349f7c4 | |||
| a5342df648 | |||
| f44b143371 | |||
| d911122a37 | |||
| a69eee191c | |||
| 90f31d3f07 | |||
| d4da05bbc9 | |||
| bd1de9e1d5 | |||
| c33eb5b593 | |||
| dcfbf89234 | |||
| 6b6d971f07 | |||
| 9fc98b5d49 | |||
| daaa6c425d | |||
| b33ef6bec2 | |||
| eb9f6a3438 | |||
| 7823ab1550 | |||
| 47fdd3588c | |||
| a9ebd2d1ac | |||
| 9c32b42256 | |||
| 26611b46e4 | |||
| e3eec8d6d1 | |||
| 2d398568da | |||
| 4051af58a7 | |||
| 3681db6cd4 | |||
| 44cefc2fe2 | |||
| 176bfaefe0 | |||
| 2372dde716 | |||
| 42ed858853 | |||
| a9fb0fc475 | |||
| 5596355141 | |||
| 9c83386655 | |||
| 98961dadd5 | |||
| 7ada99acfc | |||
| 2ac333f842 | |||
| 270a13ff60 | |||
| d800e64f93 | |||
| 390135f3bc | |||
| cad7d54e83 | |||
| 2df677d065 | |||
| 03493d4f79 | |||
| eae74c5605 | |||
| ec486214f1 | |||
| d09a09ad52 | |||
| 8b41561db9 | |||
| 73744203fb | |||
| bc385f27dd | |||
| 866b9e94af | |||
| ed7c79bcb7 | |||
| 1e4b618878 | |||
| 982861264e | |||
| 6434fe7a3c | |||
| 70e07f1965 | |||
| 87041c8842 | |||
| edb113f97f | |||
| fcd5f5dbb6 | |||
| 6849742d67 | |||
| 022e088da4 | |||
| 2471fc5aa7 | |||
| fad301ef5e | |||
| 1c83bf78fa | |||
| a6cd4ffdff | |||
| 988d27c39d | |||
| 62b954cffc | |||
| a2fed6afb8 | |||
| 97510e007e | |||
| 36f81934aa | |||
| 303bce431d | |||
| 73811e0bb7 | |||
| 0ca9f7241a | |||
| bab4c7edcd | |||
| 166b0d8ab3 | |||
| a5dbf6934c | |||
| d2bd6e7556 | |||
| 676d086ff2 | |||
| d8793f3d0b | |||
| fcf4c6e736 | |||
| fa40dfba2c | |||
| 2a17227337 | |||
| a5757dd59f | |||
| 2e10263155 | |||
| 7a7c66e801 | |||
| a55728fe46 | |||
| 8bd5e428b4 | |||
| 504b58ecf6 | |||
| 0f05b0342c | |||
| 7eea0e7669 | |||
| 0f88a74976 | |||
| 9ffd5e5ffb | |||
| 00a326029b | |||
| a47b35b965 | |||
| fcfcf2ff17 | |||
| 016567dd9a | |||
| b64dd700a8 | |||
| b0b6f5872f | |||
| e119a2c936 | |||
| 45ae8b5af2 | |||
| e28bd2fc99 | |||
| 1696162d22 | |||
| f375a2f3cc | |||
| 53bd21a7d7 | |||
| 67d8f22622 | |||
| 8433251c86 | |||
| 13449c3338 | |||
| 35c3ab4bd7 | |||
| 7356de5124 | |||
| 8a2cb10f7b | |||
| 0050072b76 | |||
| da43a81d26 | |||
| a955328070 | |||
| 53c05ab33b | |||
| 5160bd23d5 | |||
| 48ff1b3a98 | |||
| cf35dc7afd | |||
| 4b3340546f | |||
| 1d9648267b | |||
| c0ec8e567b | |||
| 2508fd8624 | |||
| de497d15a4 | |||
| 05666295e0 | |||
| 62da83f5f7 | |||
| b333910533 | |||
| 2d1da19152 | |||
| e7ac46c756 | |||
| f1a2174f96 | |||
| 89c33ac3f4 | |||
| aeda658f18 | |||
| 2f3a0219cd | |||
| 41fb5759e2 | |||
| 6a77496438 | |||
| 487f8dd886 |
@@ -1161,7 +1161,7 @@ jobs:
|
||||
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
|
||||
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
|
||||
--security-opt no-new-privileges:true \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
|
||||
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
|
||||
-v "$RUNNER_TEMP/trek/data:/app/data" \
|
||||
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
|
||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||
@@ -1194,6 +1194,17 @@ jobs:
|
||||
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|
||||
|| { echo "::error::/ does not serve the client"; exit 1; }
|
||||
|
||||
# Same capabilities as the shipped compose file: dumb-init has to be able
|
||||
# to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
|
||||
- name: Stop gracefully
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker stop -t 30 trek-ci >/dev/null
|
||||
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
|
||||
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
|
||||
docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
|
||||
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
|
||||
|
||||
- name: Container log
|
||||
if: always()
|
||||
run: docker logs trek-ci 2>&1 || true
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -234,12 +234,15 @@ the details.
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-e COOKIE_SECURE=false \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.4
|
||||
version: 5.0.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.4"
|
||||
appVersion: "5.0.0"
|
||||
|
||||
+4
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -41,9 +41,7 @@
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
@@ -94,7 +92,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 848,
|
||||
"no-console": 88,
|
||||
"complexity": 221,
|
||||
"complexity": 220,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
@@ -20,7 +20,7 @@
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 45,
|
||||
"react-refresh/only-export-components": 44,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-non-null-assertion": 1927,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
@@ -661,9 +661,24 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Demo account locks",
|
||||
"hooks": ["src/components/Demo/useDemoLock.ts"],
|
||||
"desktop": [
|
||||
"src/components/Settings/AccountDemoLock.tsx",
|
||||
"src/components/Settings/DisplaySettingsTab.tsx",
|
||||
"src/components/Settings/IntegrationsTab.tsx",
|
||||
"src/components/Settings/ai/AiConnectionSection.tsx"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/settings/MAiConnectionSection.tsx",
|
||||
"src/mobile/screens/settings/MSettingsGeneral.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMcp.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Account",
|
||||
"hooks": ["src/components/Settings/useAccountSettings.ts"],
|
||||
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
|
||||
"desktop": ["src/components/Settings/AccountTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
||||
},
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"scripts/check-gl-split.mjs",
|
||||
"scripts/check-page-pattern.mjs",
|
||||
"scripts/generate-icons.mjs",
|
||||
"scripts/patch-maplibre.mjs",
|
||||
"src/App.test.tsx",
|
||||
"src/App.tsx",
|
||||
"src/App.versionHandover.test.tsx",
|
||||
@@ -409,7 +408,6 @@
|
||||
"src/components/Journey/usePhotoZoom.ts",
|
||||
"src/components/Layout/BottomNav.test.tsx",
|
||||
"src/components/Layout/BottomNav.tsx",
|
||||
"src/components/Layout/DemoBanner.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.test.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.tsx",
|
||||
"src/components/Layout/MobileTopBar.test.tsx",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"admin.ts": 126,
|
||||
"admin.ts": 112,
|
||||
"atlas.ts": 9,
|
||||
"backup.ts": 10,
|
||||
"budget.ts": 36,
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
"src/pages/resetPassword/useResetPassword.ts",
|
||||
"src/pages/settings/useSettings.ts",
|
||||
"src/pages/sharedTrip/useSharedTrip.ts",
|
||||
"src/pages/tripPlanner/useBookingEdits.ts",
|
||||
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
|
||||
"src/pages/tripPlanner/usePlannerDialogs.ts",
|
||||
"src/pages/tripPlanner/useTripPlanner.ts",
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||
"components/Budget/BudgetPanelMemberChips.tsx": 6,
|
||||
"components/Budget/CostsPanel.tsx": 3,
|
||||
"components/Budget/ReceiptPreviewModal.tsx": 2,
|
||||
@@ -59,8 +59,6 @@
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
"components/SystemNotices/releaseNotice.css": 2,
|
||||
"components/Vacay/VacayCalendar.tsx": 3,
|
||||
"components/Vacay/VacayMonthCard.tsx": 7,
|
||||
"components/Vacay/VacaySettings.tsx": 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"components/Collections/CollectionMap.tsx": 1,
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1873,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"src/components/Map/RouteCalculator.ts": 1063,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"App.tsx": 11,
|
||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
||||
"components/Admin/DefaultUserSettingsTab.tsx": 1,
|
||||
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||
"components/Atlas/AtlasCountryPlaces.tsx": 6,
|
||||
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
|
||||
"components/Budget/BudgetPanel.helpers.ts": 2,
|
||||
@@ -51,7 +51,6 @@
|
||||
"components/Journey/journeyMapPopup.ts": 17,
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
@@ -106,7 +105,6 @@
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
"components/Todo/TodoListPanel.tsx": 6,
|
||||
@@ -207,7 +205,7 @@
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 21,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
|
||||
@@ -229,6 +229,8 @@ export const aiAdminApi = {
|
||||
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
|
||||
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
setModel: (model: string): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.patch('/admin/ai/connection/model', { model }).then((r) => r.data),
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
|
||||
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true })
|
||||
})
|
||||
|
||||
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
|
||||
|
||||
@@ -574,9 +574,9 @@ export const adminApi = {
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
|
||||
pluginInstall: (id: string, opts?: Omit<PluginInstallRequest, 'id'>) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean, accept?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, { consent: consent || undefined, acceptTrekRange: accept || undefined }).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||
// resolves the newest TREK-compatible version itself.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, I
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AddonSubRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
@@ -26,6 +27,8 @@ export default function AddonSubRow({
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
/** A small pill after the title, e.g. the beta mark on the assistant. */
|
||||
badge?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
@@ -34,8 +37,11 @@ export default function AddonSubRow({
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={`min-w-0 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
</span>
|
||||
{badge}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
|
||||
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
}
|
||||
|
||||
it('blocks Install when no published version runs on this TREK, and says why', async () => {
|
||||
it('marks a plugin no published version runs on, and still lets the admin install it', async () => {
|
||||
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
|
||||
expect(btn).toBeDisabled()
|
||||
expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('offers the newest version that DOES run here rather than a dead button', async () => {
|
||||
@@ -549,9 +549,11 @@ function panelRoot(container: HTMLElement): HTMLElement {
|
||||
}
|
||||
|
||||
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
|
||||
async function pickFilter(trigger: RegExp, option: RegExp) {
|
||||
fireEvent.click(await screen.findByTitle(trigger))
|
||||
fireEvent.click(await screen.findByRole('button', { name: option }))
|
||||
/** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
|
||||
const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
|
||||
async function pickFilter(option: RegExp) {
|
||||
if ((await screen.findByRole('button', { name: /^Filter/ })).getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton())
|
||||
fireEvent.click(await screen.findByRole('menuitemradio', { name: option }))
|
||||
}
|
||||
|
||||
/** The enable toggle of a named installed row. */
|
||||
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await pickFilter(/^Type: All types$/, /^Widget$/)
|
||||
await pickFilter(/^Widget$/)
|
||||
|
||||
expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('Countdown')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('OnOne')
|
||||
|
||||
await pickFilter(/^Status: All$/, /^Off$/)
|
||||
await pickFilter(/^Off$/)
|
||||
expect(await screen.findByText('OffOne')).toBeInTheDocument()
|
||||
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Status: Off$/, /^Error$/)
|
||||
await pickFilter(/^Error$/)
|
||||
expect(await screen.findByText('BadOne')).toBeInTheDocument()
|
||||
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Status: Error$/, /^Update available$/)
|
||||
await pickFilter(/^Update available$/)
|
||||
expect(await screen.findByText('OnOne')).toBeInTheDocument()
|
||||
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await pickFilter(/^Sort: Name$/, /^Updates first$/)
|
||||
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
|
||||
|
||||
await pickFilter(/^Updates first$/)
|
||||
expect(filterButton()).toHaveTextContent('Updates first')
|
||||
await clickDiscover()
|
||||
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
|
||||
@@ -1663,15 +1664,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — security footer', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
|
||||
describe('AdminPluginsPanel — security dialog', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
|
||||
panelWith([])
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('No plugins installed yet.')
|
||||
|
||||
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
|
||||
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
|
||||
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
|
||||
@@ -1700,9 +1702,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
|
||||
]
|
||||
|
||||
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
|
||||
await user.click(screen.getByTitle(filter))
|
||||
await user.click(screen.getByRole('button', { name: option }))
|
||||
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
|
||||
if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
|
||||
await user.click(screen.getByRole('menuitemradio', { name: option }))
|
||||
}
|
||||
|
||||
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
|
||||
@@ -1734,7 +1736,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Type:/, /^Widget$/)
|
||||
await pickFilter(user, /^Widget$/)
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
|
||||
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Active$/)
|
||||
await pickFilter(user, /^Active$/)
|
||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Off$/)
|
||||
await pickFilter(user, /^Off$/)
|
||||
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Error$/)
|
||||
await pickFilter(user, /^Error$/)
|
||||
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await screen.findByText('Alpha Widget')
|
||||
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
||||
await pickFilter(user, /^Updates first$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('Gamma Page')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Status:/, /^Update available$/)
|
||||
await pickFilter(user, /^Update available$/)
|
||||
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
||||
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
|
||||
|
||||
await pickFilter(user, /^Updates first$/)
|
||||
expect(filterButton()).toHaveTextContent('Updates first')
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
|
||||
@@ -1802,19 +1803,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await screen.findByText('Old Tool')
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Most downloads$/)
|
||||
await pickFilter(user, /^Most downloads$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Recently updated$/)
|
||||
await pickFilter(user, /^Recently updated$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
})
|
||||
|
||||
await pickFilter(user, /^Sort:/, /^Name$/)
|
||||
await pickFilter(user, /^Name$/)
|
||||
await waitFor(() => {
|
||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||
expect(names[0]).toBe('New Tool')
|
||||
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||
await screen.findByText('Mapper')
|
||||
|
||||
await pickFilter(user, /^Type:/, /^Page$/)
|
||||
await pickFilter(user, /^Page$/)
|
||||
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Poster')).toBeInTheDocument()
|
||||
|
||||
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
const { container } = render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await user.click(screen.getByTitle(/^Type:/))
|
||||
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
|
||||
await user.click(filterButton())
|
||||
expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
|
||||
|
||||
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
|
||||
fireEvent.click(layer)
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2004,12 +2005,11 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
await openDiscover(user)
|
||||
await user.click(screen.getByTitle(/^Sort:/))
|
||||
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
|
||||
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
|
||||
|
||||
await user.click(filterButton())
|
||||
await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
|
||||
expect(filterButton()).toHaveTextContent('Most downloads')
|
||||
await user.click(screen.getByRole('tab', { name: /Installed/ }))
|
||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
||||
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||
})
|
||||
|
||||
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
|
||||
@@ -2026,9 +2026,8 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
||||
expect(offer).not.toBeDisabled()
|
||||
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
|
||||
|
||||
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
|
||||
expect(dead).toBeDisabled()
|
||||
expect(dead.getAttribute('title')).toBeTruthy()
|
||||
// Nothing fits: no dead button any more, but a card that says so before anything installs.
|
||||
expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
|
||||
@@ -2556,7 +2555,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
|
||||
it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => {
|
||||
let posted: unknown = null
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
@@ -2566,7 +2565,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
|
||||
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
|
||||
const btn = await screen.findByRole('button', { name: /^install$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
fireEvent.click(btn)
|
||||
|
||||
@@ -2585,7 +2584,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
|
||||
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2,
|
||||
ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, CircleDot, Database,
|
||||
Globe, History, Puzzle, Bot, Loader2,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
@@ -23,15 +22,17 @@ import {
|
||||
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
|
||||
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
|
||||
} from '../Settings/settingsKit'
|
||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
||||
import {
|
||||
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount,
|
||||
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel,
|
||||
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey,
|
||||
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch,
|
||||
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
|
||||
installOffer, signatureBlockView,
|
||||
type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
|
||||
type T, type VersionInfo, type VersionMismatch,
|
||||
} from './plugins/pluginModel'
|
||||
import { usePluginDetail } from './plugins/usePluginDetail'
|
||||
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
import PluginFilterMenu from './plugins/PluginFilterMenu'
|
||||
import PluginInstalledRow from './plugins/PluginInstalledRow'
|
||||
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
|
||||
|
||||
/**
|
||||
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
||||
@@ -42,31 +43,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
* tile; the security section at the bottom explains the model honestly.
|
||||
*/
|
||||
|
||||
// Runtime health → dot colour on the icon tile.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-success',
|
||||
starting: 'bg-info animate-pulse',
|
||||
error: 'bg-danger',
|
||||
inactive: 'bg-content-faint/60',
|
||||
disabled: 'bg-warning',
|
||||
incompatible: 'bg-warning',
|
||||
}
|
||||
|
||||
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
|
||||
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
|
||||
/** The eyebrow over a block, the size the dialogs' sections use. */
|
||||
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
/** A capability or dependency chip on an installed row. */
|
||||
const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium'
|
||||
const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary'
|
||||
const CHIP_NET = 'border-transparent bg-info-soft text-info'
|
||||
const CHIP_WARN = 'border-transparent bg-warning-soft text-warning'
|
||||
/** A small action inside a row or a card: the kit's button, a size down. */
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
|
||||
/** A line of state under a row's name: a refused update, a paused one, an error. */
|
||||
const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5'
|
||||
|
||||
/** A pill that explains itself on hover. */
|
||||
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -76,19 +59,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t }: { t: T }) {
|
||||
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
}
|
||||
|
||||
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
|
||||
function SideloadedBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<HintPill hint={t('admin.plugins.sideloadedHint')}>
|
||||
<StatusPill tone="warning" icon={<UploadCloud size={11} />}>{t('admin.plugins.sideloaded')}</StatusPill>
|
||||
</HintPill>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
|
||||
*
|
||||
@@ -113,15 +83,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
|
||||
function DevLinkBadge({ t }: { t: T }) {
|
||||
return (
|
||||
<HintPill hint={t('admin.plugins.devLinkHint')}>
|
||||
<StatusPill icon={<Link2 size={11} />}>{t('admin.plugins.devLinkBadge')}</StatusPill>
|
||||
</HintPill>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeBadge({ type, t }: { type: string; t: T }) {
|
||||
return (
|
||||
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
|
||||
@@ -145,6 +106,7 @@ export default function AdminPluginsPanel() {
|
||||
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
|
||||
uploadPlugin, versionPicker, view,
|
||||
} = usePluginsAdmin()
|
||||
const [securityOpen, setSecurityOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative mb-24 sm:mb-0"
|
||||
@@ -181,6 +143,9 @@ export default function AdminPluginsPanel() {
|
||||
) : undefined}
|
||||
action={ready ? (
|
||||
<>
|
||||
<BandButton label={t('admin.plugins.security.short')} onClick={() => setSecurityOpen(true)}>
|
||||
<ShieldCheck size={15} strokeWidth={2} />
|
||||
</BandButton>
|
||||
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
|
||||
<UploadCloud size={15} strokeWidth={2} />
|
||||
</BandButton>
|
||||
@@ -242,31 +207,9 @@ export default function AdminPluginsPanel() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
|
||||
['trip-page', t('admin.plugins.type.trip-page')],
|
||||
]}
|
||||
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
|
||||
onPick={v => setTypeFilter(v as TypeFilter)} />
|
||||
|
||||
{view === 'installed' && (
|
||||
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
|
||||
options={[
|
||||
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
|
||||
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
|
||||
]}
|
||||
valueLabel={statusLabel(statusFilter, t)}
|
||||
onPick={v => setStatusFilter(v as StatusFilter)} />
|
||||
)}
|
||||
|
||||
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
|
||||
options={view === 'discover'
|
||||
? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
|
||||
: [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
|
||||
valueLabel={sortLabel(sort, t)}
|
||||
onPick={v => setSort(v as SortKey)} />
|
||||
<PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
|
||||
statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
|
||||
menu={menu} setMenu={setMenu} />
|
||||
</div>
|
||||
|
||||
{view === 'discover' ? (
|
||||
@@ -299,7 +242,7 @@ export default function AdminPluginsPanel() {
|
||||
) : (
|
||||
<SettingRows>
|
||||
{shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
<PluginInstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
newerIncompatible={newerIncompatible(p)}
|
||||
blocked={blockIsCurrent(p, latest[p.id])}
|
||||
@@ -320,7 +263,7 @@ export default function AdminPluginsPanel() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<SecurityInfo t={t} />
|
||||
{securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
@@ -438,268 +381,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: (
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
||||
function useFollowTrigger(open: boolean) {
|
||||
const [, reanchor] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onMove = () => reanchor(n => n + 1)
|
||||
window.addEventListener('scroll', onMove, true)
|
||||
window.addEventListener('resize', onMove)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
window.removeEventListener('resize', onMove)
|
||||
}
|
||||
}, [open])
|
||||
}
|
||||
|
||||
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
|
||||
id: string; label: string; valueLabel: string
|
||||
options: Array<[string, string]>; onPick: (v: string) => void; value: string
|
||||
menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode
|
||||
}) {
|
||||
const open = menu === id
|
||||
const active = value !== options[0]?.[0]
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
// Portaled like the row menu: the card around the toolbar clips what overflows it.
|
||||
useFollowTrigger(open)
|
||||
return (
|
||||
<div className="relative flex-none">
|
||||
<button type="button" ref={btnRef} onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
|
||||
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
||||
active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
<span className="flex-none text-content-faint">{icon}</span>
|
||||
<span>{label}: </span>
|
||||
<span className="font-semibold text-content">{valueLabel}</span>
|
||||
<ChevronDown size={13} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div className="min-w-[200px] max-w-[calc(100vw-2rem)]" style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}>
|
||||
{options.map(([v, lbl]) => (
|
||||
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
|
||||
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
const menuOpen = menu === `row:${p.id}`
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const working = busy === p.id
|
||||
const on = p.enabled === 1 && p.status !== 'error'
|
||||
|
||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||
// Re-anchor while the page moves under it.
|
||||
useFollowTrigger(menuOpen)
|
||||
|
||||
const hostCount = p.egressHostCount ?? 0
|
||||
|
||||
return (
|
||||
<div className={`group relative flex items-start gap-3 px-3.5 py-3 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}>
|
||||
<div className="relative mt-0.5 flex-none">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
||||
</span>
|
||||
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
||||
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>{p.name}</span>
|
||||
{p.version && <span className="font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>v{p.version}</span>}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
||||
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
|
||||
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
|
||||
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
|
||||
</div>
|
||||
{p.description && <p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>{p.description}</p>}
|
||||
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
|
||||
its own row state, not an error state, and it persists until it is resolved
|
||||
rather than dying with the toast that first reported it. */}
|
||||
{blocked && p.updateBlock && (
|
||||
<div className={`${ROW_NOTE} text-warning`} style={fs(11.5)}>
|
||||
<ShieldAlert size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
|
||||
<button type="button" onClick={onReviewBlock}
|
||||
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80">
|
||||
{t('admin.plugins.reviewBlock')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A newer version this TREK can't run — informational, never a button: the fix
|
||||
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
|
||||
{newerIncompatible && (
|
||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
||||
<Info size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Held: the admin deliberately installed a non-latest version, so updates are
|
||||
paused rather than nagged — resuming is one click, right where the pause shows. */}
|
||||
{p.updateHold && (
|
||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
||||
<PauseCircle size={13} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
|
||||
<button type="button" onClick={onResume} disabled={working}
|
||||
className="flex-none font-semibold text-content-secondary underline underline-offset-2 hover:opacity-80 disabled:opacity-50">
|
||||
{t('admin.plugins.resumeUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className={`${ROW_NOTE} text-danger`} style={fs(11.5)}>
|
||||
<AlertTriangle size={13} className="flex-none" /><span className="truncate">{p.last_error}</span>
|
||||
</div>
|
||||
) : (caps.length > 0 || p.operatorEgress) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className={`${CHIP} ${c.net ? CHIP_NET : CHIP_NEUTRAL}`} style={fs(11)}>
|
||||
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} /><span className="truncate">{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
|
||||
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
|
||||
Actionable, and warning-toned until at least one host exists, because
|
||||
until then the plugin cannot reach anything and looks silently broken. */}
|
||||
{p.operatorEgress && (
|
||||
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
||||
<button type="button" onClick={onEgress}
|
||||
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{deps.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`${CHIP} ${d.blocked || d.warn ? CHIP_WARN : CHIP_NEUTRAL}`} style={fs(11)}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} /><span className="truncate">{d.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 self-center">
|
||||
{hasUpdate && (
|
||||
<button type="button" onClick={onUpdate} disabled={working}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
||||
style={fs(11.5)}>
|
||||
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
|
||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
||||
<div className="relative">
|
||||
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
||||
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
|
||||
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen && createPortal(
|
||||
<div data-testid={`plugin-row-menu-${p.id}`} style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
||||
className="min-w-[200px]">
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
|
||||
an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
|
||||
never be invited to widen egress for a plugin that didn't ask for it
|
||||
(same rule the row's egress chip follows). */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted"><Github size={14} /></span> {t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
<a href={`https://github.com/${p.source_repo}/issues`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted"><CircleDot size={14} /></span> {t('admin.plugins.reportIssue')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
|
||||
const ROW_MENU_MAX_H = 260
|
||||
|
||||
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
|
||||
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
const r = trigger?.getBoundingClientRect()
|
||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
|
||||
const spaceBelow = window.innerHeight - r.bottom
|
||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||
zIndex: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of a popover menu, in the packing popovers' shape. */
|
||||
const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
||||
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>{icon}</span> {label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
return (
|
||||
@@ -751,6 +432,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
|
||||
</span>
|
||||
)}
|
||||
{/* Says up front that this one has not been confirmed for the TREK it would run on,
|
||||
so "Install" never reads as a plain install; the dialog explains the risk. */}
|
||||
{!installed && offer.warn && (
|
||||
// Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot.
|
||||
<span className="absolute start-2.5 top-2.5 inline-flex max-w-[62%] items-center gap-1 rounded-full bg-warning px-2 py-[3px] font-geist font-semibold text-[color:var(--surface-card)] shadow-md"
|
||||
style={fs(11)}>
|
||||
<AlertTriangle size={11} className="flex-none" />
|
||||
<span className="truncate">{t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
|
||||
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
|
||||
</span>
|
||||
@@ -829,8 +520,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||
*/
|
||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
||||
function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
|
||||
}) {
|
||||
return (
|
||||
<DialogShell
|
||||
@@ -844,7 +535,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
|
||||
{confirm ? (
|
||||
<>
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={onConfirm}>{t('admin.plugins.installAnyway')}</DialogButton>
|
||||
<DialogButton variant="primary" onClick={onConfirm}>{confirmLabel}</DialogButton>
|
||||
</>
|
||||
) : (
|
||||
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
|
||||
@@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
|
||||
// panel that lays out how plugins are contained, the limits, and the worst case.
|
||||
function SecurityInfo({ t }: { t: T }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const sections: Array<[string, string]> = [
|
||||
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
|
||||
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
|
||||
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
|
||||
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
|
||||
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
|
||||
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
|
||||
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
|
||||
]
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Lock}
|
||||
title={(
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
|
||||
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
|
||||
<span className="truncate">{t('admin.plugins.security.title')}</span>
|
||||
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck size={14} className="mt-0.5 flex-none text-content-faint" />
|
||||
<SettingsHint>{t('admin.plugins.reviewedMeaning')}</SettingsHint>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{sections.map(([h, b]) => (
|
||||
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>{t(h as never)}</h4>
|
||||
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>{t(b as never)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function AiAddonTile({
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
@@ -93,3 +94,16 @@ export default function AiAddonTile({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant is still young; its switch says so with a small accent pill. */
|
||||
function BetaBadge({ label }: { label: string }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-[7px] py-[2px] font-geist font-bold uppercase leading-none tracking-[.08em] text-accent-text shadow-sm"
|
||||
style={fs(9)}
|
||||
>
|
||||
<Sparkles size={9} strokeWidth={2.6} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { openrouterReturnCode } from '../../Settings/ai/aiConnectModel';
|
||||
import type { Addon } from './addonModel';
|
||||
import { usesConnection } from './aiAddons';
|
||||
|
||||
@@ -11,7 +12,7 @@ import { usesConnection } from './aiAddons';
|
||||
*/
|
||||
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||
const [open, setOpen] = useState(() => openrouterReturnCode(params) !== undefined);
|
||||
const canSetUp = usesConnection(aiAddons);
|
||||
return {
|
||||
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-008: the AI assistant's settings and connection for everyone.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -14,6 +16,7 @@ const repo = vi.hoisted(() => ({
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
setAdminModel: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
@@ -59,6 +62,8 @@ async function renderLoaded() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The settings show while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
@@ -153,6 +158,29 @@ describe('useAiAdminConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("FE-HOOK-AI-ADMIN-008: a shared ChatGPT plan's model is switched on its own, and the answer shows (#2783)", async () => {
|
||||
const plan = (model: string): AiAdminConnectionResponse => ({
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'chatgpt',
|
||||
model,
|
||||
hint: null,
|
||||
status: 'ok',
|
||||
host: 'api.openai.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
});
|
||||
repo.adminConnection.mockResolvedValue(plan('gpt-6.1-sol'));
|
||||
repo.setAdminModel.mockResolvedValue(plan('gpt-6.1-sol-mini'));
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.instance.setModel('gpt-6.1-sol-mini'));
|
||||
await waitFor(() => expect(repo.setAdminModel).toHaveBeenCalledWith('gpt-6.1-sol-mini'));
|
||||
expect(repo.saveAdminConnection).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(result.current.instance.live?.model).toBe('gpt-6.1-sol-mini'));
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
|
||||
const { result } = await renderLoaded();
|
||||
|
||||
@@ -124,9 +124,16 @@ function useInstanceConnection(onChanged: () => void) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
|
||||
/**
|
||||
* Another model on the same connection: the stored key is kept by sending the mask in its place.
|
||||
* A shared ChatGPT plan has a sign-in, not a key, so its model is switched on its own (#2783).
|
||||
*/
|
||||
const setModel = (model: string) => {
|
||||
if (!live || live.kind === 'chatgpt') return;
|
||||
if (!live) return;
|
||||
if (live.kind === 'chatgpt') {
|
||||
void act(() => aiConnectionsRepo.setAdminModel(model), 'common.saved');
|
||||
return;
|
||||
}
|
||||
const request: AiInstanceConnectionRequest =
|
||||
live.kind === 'custom'
|
||||
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||
import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel';
|
||||
|
||||
const MENU_ID = 'filters';
|
||||
|
||||
interface Section {
|
||||
title: string;
|
||||
value: string;
|
||||
options: Array<[string, string]>;
|
||||
onPick: (v: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type, status and order of the plugin list behind one button instead of three, so the
|
||||
* toolbar stays search plus one control. The button counts the filters that narrow the
|
||||
* list (the order does not narrow it) and names the order it is in.
|
||||
*/
|
||||
export default function PluginFilterMenu({
|
||||
t,
|
||||
view,
|
||||
typeFilter,
|
||||
setTypeFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
sort,
|
||||
setSort,
|
||||
menu,
|
||||
setMenu,
|
||||
}: {
|
||||
t: T;
|
||||
view: 'installed' | 'discover';
|
||||
typeFilter: TypeFilter;
|
||||
setTypeFilter: (v: TypeFilter) => void;
|
||||
statusFilter: StatusFilter;
|
||||
setStatusFilter: (v: StatusFilter) => void;
|
||||
sort: SortKey;
|
||||
setSort: (v: SortKey) => void;
|
||||
menu: string | null;
|
||||
setMenu: (v: string | null) => void;
|
||||
}) {
|
||||
const open = menu === MENU_ID;
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
useFollowTrigger(open);
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: t('admin.plugins.filterType'),
|
||||
value: typeFilter,
|
||||
options: [
|
||||
['all', t('admin.plugins.allTypes')],
|
||||
['widget', t('admin.plugins.type.widget')],
|
||||
['integration', t('admin.plugins.type.integration')],
|
||||
['page', t('admin.plugins.type.page')],
|
||||
['trip-page', t('admin.plugins.type.trip-page')],
|
||||
],
|
||||
onPick: (v) => setTypeFilter(v as TypeFilter),
|
||||
},
|
||||
...(view === 'installed'
|
||||
? [
|
||||
{
|
||||
title: t('admin.plugins.filterStatus'),
|
||||
value: statusFilter,
|
||||
options: [
|
||||
['all', t('admin.plugins.allStatuses')],
|
||||
['on', t('admin.plugins.status.active')],
|
||||
['off', t('admin.plugins.stateOff')],
|
||||
['update', t('admin.plugins.filterUpdate')],
|
||||
['err', t('admin.plugins.status.error')],
|
||||
] as Array<[string, string]>,
|
||||
onPick: (v: string) => setStatusFilter(v as StatusFilter),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t('admin.plugins.sortBy'),
|
||||
value: sort,
|
||||
options:
|
||||
view === 'discover'
|
||||
? [
|
||||
['name', t('admin.plugins.sortName')],
|
||||
['recent', t('admin.plugins.sortRecent')],
|
||||
['downloads', t('admin.plugins.sortDownloads')],
|
||||
]
|
||||
: [
|
||||
['name', t('admin.plugins.sortName')],
|
||||
['recent', t('admin.plugins.sortRecent')],
|
||||
['updates', t('admin.plugins.sortUpdates')],
|
||||
],
|
||||
onPick: (v) => setSort(v as SortKey),
|
||||
},
|
||||
];
|
||||
const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="relative flex-none">
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
onClick={() => setMenu(open ? null : MENU_ID)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={`inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
||||
narrowing ? 'border-[color:var(--text-primary)]' : 'border-edge'
|
||||
}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<SlidersHorizontal size={14} className="flex-none text-content-faint" />
|
||||
<span className="font-semibold text-content">{t('admin.plugins.filters')}</span>
|
||||
{narrowing > 0 && (
|
||||
<span
|
||||
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1.5 font-geist font-bold tabular-nums text-accent-text"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{narrowing}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-content-faint">{sortLabel(sort, t)}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
className="w-[240px] max-w-[calc(100vw-2rem)]"
|
||||
style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}
|
||||
>
|
||||
{sections.map((s, i) => (
|
||||
<div key={s.title} className={i > 0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}>
|
||||
<p
|
||||
className="m-0 px-2.5 pb-1 pt-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(10)}
|
||||
>
|
||||
{s.title}
|
||||
</p>
|
||||
{s.options.map(([v, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={s.value === v}
|
||||
key={v}
|
||||
onClick={() => s.onPick(v)}
|
||||
className={`${MENU_ITEM} ${s.value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
|
||||
<Check
|
||||
size={14}
|
||||
className={`flex-none text-content-muted ${s.value === v ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
Bug,
|
||||
CircleDot,
|
||||
Github,
|
||||
Globe,
|
||||
History,
|
||||
Info,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PauseCircle,
|
||||
RotateCw,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { useRef, type ComponentType, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import PluginIcon from '../../shared/PluginIcon';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { deriveCaps } from '../pluginCaps';
|
||||
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||
import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel';
|
||||
|
||||
// Runtime health → dot colour on the icon tile.
|
||||
const HEALTH: Record<string, string> = {
|
||||
active: 'bg-success',
|
||||
starting: 'bg-info animate-pulse',
|
||||
error: 'bg-danger',
|
||||
inactive: 'bg-content-faint/60',
|
||||
disabled: 'bg-warning',
|
||||
incompatible: 'bg-warning',
|
||||
};
|
||||
|
||||
/** Every fact about a plugin wears the same chip; only its icon carries a colour. */
|
||||
const FACT =
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary';
|
||||
|
||||
type Tone = 'warning' | 'danger' | 'neutral';
|
||||
const NOTICE_LOOK: Record<Tone, string> = {
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
neutral: 'bg-surface-secondary text-content-muted',
|
||||
};
|
||||
|
||||
/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */
|
||||
interface Notice {
|
||||
key: string;
|
||||
tone: Tone;
|
||||
icon: ComponentType<{ size?: number; className?: string }>;
|
||||
text: string;
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
}
|
||||
|
||||
function Fact({
|
||||
icon,
|
||||
iconClass = 'text-content-muted',
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
iconClass?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const chip = (
|
||||
<span className={FACT} style={fs(11)}>
|
||||
<span className={`flex flex-none ${iconClass}`}>{icon}</span>
|
||||
<span className="truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
return hint ? (
|
||||
<Tooltip label={hint}>
|
||||
<span className="inline-flex max-w-full">{chip}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
chip
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeLine({ n }: { n: Notice }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-1.5 ${NOTICE_LOOK[n.tone]}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<n.icon size={13} className="flex-none" />
|
||||
<span className="min-w-0 flex-1 truncate">{n.text}</span>
|
||||
{n.action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={n.action.onClick}
|
||||
disabled={n.action.disabled}
|
||||
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80 disabled:opacity-50"
|
||||
>
|
||||
{n.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>
|
||||
{icon}
|
||||
</span>{' '}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface InstalledRowProps {
|
||||
p: PluginRow;
|
||||
t: T;
|
||||
busy: string | null;
|
||||
menu: string | null;
|
||||
setMenu: (v: string | null) => void;
|
||||
hasUpdate: boolean;
|
||||
latestVer?: string;
|
||||
newerIncompatible: { version: string; range: string } | null;
|
||||
blocked: boolean;
|
||||
onToggle: () => void;
|
||||
onUpdate: () => void;
|
||||
onRestart: () => void;
|
||||
onChangeVersion: () => void;
|
||||
onResume: () => void;
|
||||
onReviewBlock: () => void;
|
||||
onErrors: () => void;
|
||||
onEgress: () => void;
|
||||
onSettings: () => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed plugin, read top to bottom: its name and version, what it does, the
|
||||
* facts about it as one row of alike chips (what it adds, whether it was reviewed and
|
||||
* signed, where it may connect), then each state that needs attention on a line of its
|
||||
* own with the action that resolves it. The controls stay on the right.
|
||||
*/
|
||||
export default function PluginInstalledRow(props: InstalledRowProps) {
|
||||
const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props;
|
||||
const caps = deriveCaps(
|
||||
parseJson<string[]>(p.permissions, []),
|
||||
parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
|
||||
t
|
||||
);
|
||||
const deps = deriveDeps(p, t);
|
||||
const menuOpen = menu === `row:${p.id}`;
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const working = busy === p.id;
|
||||
const hostCount = p.egressHostCount ?? 0;
|
||||
|
||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||
useFollowTrigger(menuOpen);
|
||||
|
||||
const notices: Notice[] = [];
|
||||
if (p.status === 'error' && p.last_error) {
|
||||
notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error });
|
||||
}
|
||||
// A refused update leaves a WORKING plugin pinned at its old version: its own state,
|
||||
// kept until resolved rather than dying with the toast that first reported it.
|
||||
if (blocked && p.updateBlock) {
|
||||
notices.push({
|
||||
key: 'block',
|
||||
tone: 'warning',
|
||||
icon: ShieldAlert,
|
||||
text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }),
|
||||
action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock },
|
||||
});
|
||||
}
|
||||
for (const [i, d] of deps.entries()) {
|
||||
if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label });
|
||||
}
|
||||
// Held: the admin deliberately installed a non-latest version, so updates are paused
|
||||
// rather than nagged — resuming is one click, right where the pause shows.
|
||||
if (p.updateHold) {
|
||||
notices.push({
|
||||
key: 'hold',
|
||||
tone: 'neutral',
|
||||
icon: PauseCircle,
|
||||
text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }),
|
||||
action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working },
|
||||
});
|
||||
}
|
||||
// A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade.
|
||||
if (newerIncompatible) {
|
||||
notices.push({
|
||||
key: 'newer',
|
||||
tone: 'neutral',
|
||||
icon: Info,
|
||||
text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-3 px-3.5 py-3.5 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="relative mt-0.5 flex-none">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
||||
</span>
|
||||
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>
|
||||
{p.name}
|
||||
</span>
|
||||
{p.version && (
|
||||
<span className="flex-none font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>
|
||||
v{p.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{caps.map((c, i) => (
|
||||
<Fact key={i} icon={<c.icon size={12} />} iconClass={c.net ? 'text-info' : 'text-content-muted'}>
|
||||
{c.label}
|
||||
</Fact>
|
||||
))}
|
||||
{deps
|
||||
.filter((d) => !d.blocked && !d.warn)
|
||||
.map((d, i) => (
|
||||
<Fact key={`d${i}`} icon={<d.icon size={12} />}>
|
||||
{d.label}
|
||||
</Fact>
|
||||
))}
|
||||
{p.reviewed_at && (
|
||||
<Fact icon={<ShieldCheck size={12} />} iconClass="text-success">
|
||||
{t('admin.plugins.reviewed')}
|
||||
</Fact>
|
||||
)}
|
||||
{p.source_repo === 'local:upload' && (
|
||||
<Fact icon={<UploadCloud size={12} />} iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}>
|
||||
{t('admin.plugins.sideloaded')}
|
||||
</Fact>
|
||||
)}
|
||||
{p.source_repo === 'local:link' && (
|
||||
<Fact icon={<Link2 size={12} />} hint={t('admin.plugins.devLinkHint')}>
|
||||
{t('admin.plugins.devLinkBadge')}
|
||||
</Fact>
|
||||
)}
|
||||
{/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */}
|
||||
{isRegistrySourced(p.source_repo) &&
|
||||
(p.signed ? (
|
||||
<Fact icon={<KeyRound size={12} />} hint={t('admin.plugins.signedHint')}>
|
||||
{t('admin.plugins.signed')}
|
||||
</Fact>
|
||||
) : (
|
||||
<Fact icon={<ShieldAlert size={12} />} iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}>
|
||||
{t('admin.plugins.unsigned')}
|
||||
</Fact>
|
||||
))}
|
||||
{/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds
|
||||
its host, and until one exists the plugin reaches nothing, so it asks for it. */}
|
||||
{p.operatorEgress && (
|
||||
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
||||
<button type="button" onClick={props.onEgress} className={`${FACT} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} className={hostCount > 0 ? 'text-info' : 'text-warning'} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notices.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||
{notices.map((n) => (
|
||||
<NoticeLine key={n.key} n={n} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none items-center gap-2 self-center">
|
||||
{hasUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onUpdate}
|
||||
disabled={working}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`min-w-[42px] text-end font-medium ${p.enabled === 1 ? 'text-content-secondary' : 'text-content-faint'}`}
|
||||
style={fs(12)}
|
||||
>
|
||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={props.onToggle} />
|
||||
<div className="relative">
|
||||
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
||||
<button
|
||||
type="button"
|
||||
ref={menuBtnRef}
|
||||
data-testid={`plugin-row-menu-btn-${p.id}`}
|
||||
onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={t('admin.table.actions')}
|
||||
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
|
||||
>
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{menuOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
data-testid={`plugin-row-menu-${p.id}`}
|
||||
style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem
|
||||
icon={<RotateCw size={14} />}
|
||||
label={t('admin.plugins.restart')}
|
||||
onClick={props.onRestart}
|
||||
/>
|
||||
)}
|
||||
{/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
label={t('admin.plugins.instanceSettings')}
|
||||
onClick={props.onSettings}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={props.onErrors} />
|
||||
{/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem
|
||||
icon={<Globe size={14} />}
|
||||
label={t('admin.plugins.allowedHosts')}
|
||||
onClick={props.onEgress}
|
||||
/>
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem
|
||||
icon={<History size={14} />}
|
||||
label={t('admin.plugins.changeVersion')}
|
||||
onClick={props.onChangeVersion}
|
||||
/>
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a
|
||||
href={`https://github.com/${p.source_repo}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||
<Github size={14} />
|
||||
</span>{' '}
|
||||
{t('admin.plugins.sourceRepo')}
|
||||
</a>
|
||||
<a
|
||||
href={`https://github.com/${p.source_repo}/issues`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setMenu(null)}
|
||||
className={`${MENU_ITEM} text-content-secondary`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||
<CircleDot size={14} />
|
||||
</span>{' '}
|
||||
{t('admin.plugins.reportIssue')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={props.onUninstall} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import type { T } from './pluginModel';
|
||||
|
||||
/**
|
||||
* How plugins are contained and where that ends, opened from the shield in the panel's
|
||||
* header. It used to sit under the list as a card of its own, which pushed a wall of
|
||||
* text into the page every admin scrolls past.
|
||||
*/
|
||||
export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) {
|
||||
const sections: Array<[string, string]> = [
|
||||
[t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')],
|
||||
[t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')],
|
||||
[t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')],
|
||||
[t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')],
|
||||
[t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')],
|
||||
[t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')],
|
||||
[t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')],
|
||||
];
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy="plugin-security-title"
|
||||
width="detail"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<ShieldCheck size={20} className="text-content-secondary" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId="plugin-security-title"
|
||||
title={t('admin.plugins.security.short')}
|
||||
sub={t('admin.plugins.reviewedMeaning')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.ok')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{sections.map(([h, b]) => (
|
||||
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{h}
|
||||
</h4>
|
||||
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>
|
||||
{b}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
|
||||
/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */
|
||||
const ROW_MENU_MAX_H = 260;
|
||||
|
||||
/**
|
||||
* Where a portaled menu sits: right-aligned under its trigger, or above it when the
|
||||
* window has no room below. Fixed, because the cards around the toolbar and the rows
|
||||
* clip whatever overflows them.
|
||||
*/
|
||||
export function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
const r = trigger?.getBoundingClientRect();
|
||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
|
||||
const spaceBelow = window.innerHeight - r.bottom;
|
||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||
zIndex: 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** One entry of a popover menu, in the packing popovers' shape. */
|
||||
export const MENU_ITEM =
|
||||
'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary';
|
||||
|
||||
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
||||
export function useFollowTrigger(open: boolean) {
|
||||
const [, reanchor] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMove = () => reanchor((n) => n + 1);
|
||||
window.addEventListener('scroll', onMove, true);
|
||||
window.addEventListener('resize', onMove);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onMove, true);
|
||||
window.removeEventListener('resize', onMove);
|
||||
};
|
||||
}, [open]);
|
||||
}
|
||||
@@ -111,8 +111,14 @@ describe('pluginModel', () => {
|
||||
blocked: false,
|
||||
version: '0.9.0',
|
||||
});
|
||||
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' });
|
||||
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' });
|
||||
// Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
|
||||
expect(installOffer(incompatible, t)).toMatchObject({
|
||||
blocked: false,
|
||||
label: 'admin.plugins.install',
|
||||
warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' },
|
||||
});
|
||||
// TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice.
|
||||
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
|
||||
|
||||
@@ -298,7 +298,8 @@ export function installOffer(
|
||||
title,
|
||||
};
|
||||
}
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title };
|
||||
// No version fits this TREK: the admin may still install this one plugin at their own risk.
|
||||
return bypassOffer(item, t, title, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells.
|
||||
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
|
||||
expect(result.current.confirmUninstall).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
|
||||
serve([
|
||||
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
|
||||
]);
|
||||
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
|
||||
const { result } = await mounted();
|
||||
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
|
||||
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
|
||||
expect(result.current.bypass.warning).toMatchObject({
|
||||
acceptedByAdmin: true,
|
||||
action: 'activate',
|
||||
trekRange: '>=3.2.0 <4.0.0',
|
||||
});
|
||||
await act(async () => {
|
||||
result.current.bypass.confirm();
|
||||
});
|
||||
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
|
||||
serve([], [entry({ id: 'beta', name: 'Beta' })]);
|
||||
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
|
||||
const { result } = await mounted();
|
||||
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
|
||||
act(() => result.current.install('beta', undefined, warn));
|
||||
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
result.current.bypass.confirm();
|
||||
});
|
||||
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,9 +317,14 @@ export function usePluginsAdmin() {
|
||||
act(
|
||||
id,
|
||||
() =>
|
||||
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
|
||||
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
||||
}),
|
||||
adminApi
|
||||
.pluginInstall(id, {
|
||||
...(version ? { version } : {}),
|
||||
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
||||
}),
|
||||
t('admin.plugins.installed')
|
||||
)
|
||||
);
|
||||
@@ -392,10 +397,10 @@ export function usePluginsAdmin() {
|
||||
}
|
||||
};
|
||||
|
||||
const attemptActivate = (p: PluginRow) => {
|
||||
const attemptActivate = (p: PluginRow, acceptTrekRange = false) => {
|
||||
const cascaded = autoEnabledDeps(p);
|
||||
return adminApi
|
||||
.pluginActivate(p.id)
|
||||
.pluginActivate(p.id, false, acceptTrekRange)
|
||||
.then(() => {
|
||||
toast.success(t('admin.plugins.activated'));
|
||||
if (cascaded.length)
|
||||
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
|
||||
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
|
||||
return;
|
||||
}
|
||||
setBusy(p.id);
|
||||
setMenu(null);
|
||||
attemptActivate(p).finally(() => {
|
||||
setBusy(null);
|
||||
refresh();
|
||||
});
|
||||
const run = (acceptTrekRange: boolean) => {
|
||||
setBusy(p.id);
|
||||
setMenu(null);
|
||||
attemptActivate(p, acceptTrekRange).finally(() => {
|
||||
setBusy(null);
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
// Outside the TREK versions its author declared: the admin may run it anyway, after
|
||||
// reading what that risks. The choice holds for this version of the plugin only.
|
||||
if (p.dependencyStatus === 'hostIncompatible') {
|
||||
const warn: RangeWarning = {
|
||||
name: p.name,
|
||||
trekRange: p.trekRange ?? null,
|
||||
hostVersion: p.hostVersion ?? '?',
|
||||
acceptedByAdmin: true,
|
||||
onConfirm: null,
|
||||
action: 'activate',
|
||||
};
|
||||
bypass.guard(warn, () => run(true));
|
||||
return;
|
||||
}
|
||||
run(false);
|
||||
};
|
||||
|
||||
// Download a missing/outdated plugin dependency (latest compatible for its range,
|
||||
|
||||
@@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => {
|
||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||
// An admin's own "run it anyway" for this one plugin says so, not "version checks off".
|
||||
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||
warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||
label: 'admin.plugins.dep.trekAcceptedUnknown',
|
||||
})
|
||||
})
|
||||
|
||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
||||
it('bypassOffer: an enabled Install that carries what the dialog must say first', () => {
|
||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||
expect(offer).toEqual({
|
||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
||||
blocked: false, label: 'admin.plugins.install', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null },
|
||||
})
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true })
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
|
||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => {
|
||||
const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} }
|
||||
expect(rangeWarningCopy(mine, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin',
|
||||
body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}',
|
||||
confirmLabel: 'admin.plugins.installAnyway',
|
||||
})
|
||||
expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({
|
||||
body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}',
|
||||
confirmLabel: 'admin.plugins.activateAnyway',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n'
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
||||
/**
|
||||
* The server's "this only went through because the version check was set aside" marker:
|
||||
* by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE.
|
||||
*/
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean }
|
||||
/**
|
||||
* What the range-bypass warning needs to say, and what to do if the admin accepts (null = a
|
||||
* notice). `action` names what the confirm button does: install (the default) or activate.
|
||||
*/
|
||||
export interface RangeWarning extends TrekRangeBypass {
|
||||
name: string
|
||||
onConfirm: (() => void) | null
|
||||
action?: 'install' | 'activate'
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip a row carries while it runs outside its declared range on the operator's
|
||||
@@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm:
|
||||
*/
|
||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||
if (!b) return null
|
||||
const label = b.acceptedByAdmin
|
||||
? b.trekRange
|
||||
? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekAcceptedUnknown')
|
||||
: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown')
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
||||
label,
|
||||
blocked: false,
|
||||
warn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Install button for a registry entry the server flagged incompatible, once the
|
||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
* The Install button for a registry entry the server flagged incompatible: the server will
|
||||
* take the newest version regardless, so the button says exactly that — and carries what
|
||||
* the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
|
||||
* plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin.
|
||||
* `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
*/
|
||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
||||
return {
|
||||
blocked: false,
|
||||
label: t('admin.plugins.installAnyway'),
|
||||
title,
|
||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
||||
export function bypassOffer(
|
||||
item: { name: string; trek?: string | null; hostVersion?: string },
|
||||
t: T,
|
||||
title: string,
|
||||
byAdmin = false
|
||||
) {
|
||||
const warn: RangeWarning = {
|
||||
name: item.name,
|
||||
trekRange: item.trek ?? null,
|
||||
hostVersion: item.hostVersion ?? '?',
|
||||
acceptedByAdmin: byAdmin,
|
||||
onConfirm: null,
|
||||
}
|
||||
return { blocked: false, label: t('admin.plugins.install'), title, warn }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers
|
||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||
* that, in rare cases, that can cost them data.
|
||||
*/
|
||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
||||
export function rangeWarningCopy(
|
||||
w: RangeWarning,
|
||||
t: T
|
||||
): { title: string; body: string; confirm: boolean; confirmLabel: string } {
|
||||
const confirm = w.onConfirm !== null
|
||||
const vars = { name: w.name, range: w.trekRange, host: w.hostVersion }
|
||||
const body = w.acceptedByAdmin
|
||||
? w.trekRange
|
||||
? t('admin.plugins.rangeBypass.bodyAdmin', vars)
|
||||
: t('admin.plugins.rangeBypass.bodyAdminUnknown', vars)
|
||||
: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', vars)
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', vars)
|
||||
const title = w.acceptedByAdmin
|
||||
? t('admin.plugins.rangeBypass.titleAdmin')
|
||||
: confirm
|
||||
? t('admin.plugins.rangeBypass.title')
|
||||
: t('admin.plugins.rangeBypass.noticeTitle')
|
||||
return {
|
||||
confirm,
|
||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
||||
body: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
||||
title,
|
||||
body,
|
||||
confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AssistantSurface({
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
journal,
|
||||
}: AssistantSurfaceProps) {
|
||||
const runtime = useAssistantRuntime(tripId);
|
||||
// The phone is always compact; a window is what its width says.
|
||||
@@ -43,8 +44,9 @@ export default function AssistantSurface({
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
journal,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat, journal]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* FE-AI-WELCOME-001 to FE-AI-WELCOME-002
|
||||
*
|
||||
* The empty chat asks what fits the chat it is: a journey's conversation
|
||||
* shown for its journal asks about the journal, the same conversation in TREK
|
||||
* Studio about the book, and each tells a reader what they may not change.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantWelcome from './AssistantWelcome';
|
||||
import { AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const JOURNAL_KEY = -12;
|
||||
|
||||
function welcome(canEdit: boolean, journal?: boolean) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: JOURNAL_KEY, variant: 'window', journal }}>
|
||||
<AssistantWelcome suggestions={[]} canEdit={canEdit} withSuggestions={false} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AssistantWelcome', () => {
|
||||
it("FE-AI-WELCOME-001: a journey's chat shown for its journal asks about the journal", () => {
|
||||
welcome(false, true);
|
||||
expect(screen.getByText('What should go into the journal?')).toBeInTheDocument();
|
||||
expect(screen.getByText(/You can only read this journal/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WELCOME-002: the same chat in the Studio asks about the book', () => {
|
||||
welcome(true);
|
||||
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/You can only/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -27,33 +27,40 @@ export default function AssistantWelcome({
|
||||
withSuggestions: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
|
||||
const scope = useAssistantScope().kind;
|
||||
const book = scope === 'journey';
|
||||
const { tripId, variant, journal } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan; the journey's chat shown for its journal about the journal,
|
||||
// the home chat about all of the user's travels.
|
||||
const about = useAssistantScope().kind;
|
||||
const scope: WelcomeScope = about === 'journey' && journal ? 'journal' : about;
|
||||
const hints = useAssistantHints(tripId, { withStays: true });
|
||||
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
||||
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||
{!canEdit && (
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
|
||||
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
|
||||
</p>
|
||||
)}
|
||||
{!canEdit && <p className="m-0 max-w-xs text-center text-caption text-content-muted">{readOnlyNote(scope, t)}</p>}
|
||||
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
|
||||
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What an empty chat is about: a trip, a journey's book in the Studio, a journal on its page, or all of them. */
|
||||
type WelcomeScope = 'trip' | 'journey' | 'journal' | 'home';
|
||||
|
||||
/** The question over the empty chat, by what the chat is about. */
|
||||
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
|
||||
function welcomeTitle(scope: WelcomeScope, t: (key: string) => string): string {
|
||||
if (scope === 'journey') return t('journey.studio.assistant.welcome');
|
||||
if (scope === 'journal') return t('assistant.journal.welcome');
|
||||
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
|
||||
}
|
||||
|
||||
/** What the empty chat tells someone who may only read. */
|
||||
function readOnlyNote(scope: WelcomeScope, t: (key: string) => string): string {
|
||||
if (scope === 'journey') return t('journey.studio.assistant.readOnly');
|
||||
return scope === 'journal' ? t('assistant.journal.readOnly') : t('assistant.empty.readOnly');
|
||||
}
|
||||
|
||||
export function AssistantSuggestions({
|
||||
suggestions,
|
||||
layout,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router';
|
||||
|
||||
import type { WindowRect } from '../../hooks/useDragResize';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { HOME_CHAT_KEY, assistantScopeOf } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import AssistantFrameEdges from './AssistantFrameEdges';
|
||||
import AssistantHeader from './AssistantHeader';
|
||||
@@ -15,8 +15,9 @@ import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { useAssistantHome } from './useAssistantHome';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,9 @@ import { useAssistantThread } from './useAssistantThread';
|
||||
*
|
||||
* Outside a trip it opens on the home chat, which knows all of the user's
|
||||
* trips; its head lists them to go on in one trip's own chat, and a trip's
|
||||
* chat opened that way has the way back home in its head.
|
||||
* chat opened that way has the way back home in its head. On a journey's
|
||||
* page it opens on that journal's chat, the journey's conversation it shares
|
||||
* with TREK Studio.
|
||||
*
|
||||
* Mounted once for the whole app; it shows nothing until the assistant has a
|
||||
* connection this user can use.
|
||||
@@ -170,26 +173,42 @@ function ChatWindow({
|
||||
onFocusDay={onFocusDay}
|
||||
onRevealMap={onRevealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
// The window never shows the Studio: a journey's chat in it is the journal's.
|
||||
journal={assistantScopeOf(trip.id).kind === 'journey'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which chat the window shows: the page's own (the trip in the planner, else
|
||||
* the journal on screen), else the one picked, else the home chat. `onScreen`
|
||||
* when it is the page's own, `inPlanner` when that page is the planner.
|
||||
*/
|
||||
function windowChat(
|
||||
trip: WindowTrip | null,
|
||||
journal: WindowTrip | null,
|
||||
picked: WindowTrip | null,
|
||||
homeChat: WindowTrip
|
||||
) {
|
||||
const routeChat = trip ?? journal;
|
||||
const current = routeChat ?? picked ?? homeChat;
|
||||
const onScreen = !!routeChat && current.id === routeChat.id;
|
||||
return { routeChat, current, onScreen, inPlanner: onScreen && trip !== null };
|
||||
}
|
||||
|
||||
export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap }: AssistantWindowProps) {
|
||||
const navigate = useNavigate();
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
const home = useAssistantHome(setPicked);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
useAssistantStatus();
|
||||
useAssistantHotkey(available, toggle);
|
||||
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
@@ -198,13 +217,14 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
if (!open) setPicked(null);
|
||||
}
|
||||
|
||||
const current = trip ?? picked ?? home.chat;
|
||||
const journal = useRouteJournalChat();
|
||||
const { routeChat, current, onScreen, inPlanner } = windowChat(trip, journal, picked, home.chat);
|
||||
const atHome = current.id === HOME_CHAT_KEY;
|
||||
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
|
||||
// A change in a trip the planner does not show is shown by opening that trip (a journal, its page); a tab opens on it.
|
||||
const openPicked = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
if (target.kind === 'page') void navigate(target.path);
|
||||
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
|
||||
else if (picked) void navigate(`${chatPagePath(picked)}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
|
||||
},
|
||||
[navigate, picked]
|
||||
);
|
||||
@@ -216,19 +236,20 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
[navigate]
|
||||
);
|
||||
const goHome = useCallback(() => setPicked(null), []);
|
||||
// Another page leaves the trip: the chat stays on it, as if it had been picked.
|
||||
// Another page leaves the trip or the journal: the chat stays on it, as if it had been picked.
|
||||
const openOnScreen = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
if (target.kind === 'page' && trip) setPicked(trip);
|
||||
if (target.kind === 'page' && routeChat) setPicked(routeChat);
|
||||
onOpen?.(target);
|
||||
},
|
||||
[onOpen, trip]
|
||||
[onOpen, routeChat]
|
||||
);
|
||||
|
||||
// The round button's hint dot reads a trip's plan; a journal has none.
|
||||
const launcherTrip = trip?.id ?? null;
|
||||
if (!available) return null;
|
||||
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
|
||||
if (!open) return routeChat ? <AssistantLauncher variant="floating" tripId={launcherTrip} /> : null;
|
||||
|
||||
const onScreen = !!trip && current.id === trip.id;
|
||||
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
|
||||
if (atHome) openTarget = openFromHome;
|
||||
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
|
||||
@@ -241,13 +262,13 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
hidden={minimized}
|
||||
onOpen={openTarget}
|
||||
// Only the planner on screen has days to select and a map to bring back.
|
||||
onFocusDay={onScreen ? onFocusDay : undefined}
|
||||
onRevealMap={onScreen ? onRevealMap : undefined}
|
||||
onFocusDay={inPlanner ? onFocusDay : undefined}
|
||||
onRevealMap={inPlanner ? onRevealMap : undefined}
|
||||
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
||||
tripSwitch={tripSwitch}
|
||||
/>
|
||||
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
||||
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
|
||||
{minimized && <AssistantLauncher variant="floating" tripId={launcherTrip} focusOnMount />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,4 +121,40 @@ describe('the questions an answer offers', () => {
|
||||
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
||||
});
|
||||
|
||||
it('FE-AI-FOLLOW-008: the answer to an attached file offers what the server chose, also under its booking cards', () => {
|
||||
const read = {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: 'r',
|
||||
toolName: 'read_booking_file',
|
||||
args: { name: 'AirBnB Buchung.pdf' },
|
||||
result: { bookings: [], reason: 'ai_not_configured' },
|
||||
};
|
||||
const chips = {
|
||||
type: 'data-trek-follow-ups' as const,
|
||||
data: { questions: ['Keep it in Files', 'What is in the file?'] },
|
||||
};
|
||||
const empty = of([
|
||||
read,
|
||||
{ type: 'text', text: 'I could not find a booking. What should I do with the file?' },
|
||||
chips,
|
||||
]);
|
||||
expect(followUpsFor(empty, true).map((f) => f.text)).toEqual(['Keep it in Files', 'What is in the file?']);
|
||||
const change = {
|
||||
toolCallId: 'b',
|
||||
verbKey: 'importBooking',
|
||||
subject: 'Casa',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: false,
|
||||
};
|
||||
const found = of([
|
||||
read,
|
||||
{ type: 'text', text: 'I found 1 booking.' },
|
||||
{ type: 'data-trek-change', data: change as AssistantChangePreview },
|
||||
{ type: 'data-trek-follow-ups', data: { questions: ['Keep the file with this booking'] } },
|
||||
]);
|
||||
expect(followUpsFor(found, true).map((f) => f.text)).toEqual(['Keep the file with this booking']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -463,6 +463,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
track: { labelKey: 'assistant.change.label.track' },
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
sentTo: { labelKey: 'assistant.change.label.sentTo' },
|
||||
attachedTo: { labelKey: 'assistant.change.label.attachedTo' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface AssistantView {
|
||||
* chat's card that offers it; left out where no chat can be switched to.
|
||||
*/
|
||||
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
|
||||
/**
|
||||
* A journey's chat shown for its journal (on the journey's page, from the
|
||||
* home chat), not for its book in TREK Studio; left out in the Studio and
|
||||
* for a trip. The empty chat then speaks of the journal.
|
||||
*/
|
||||
journal?: boolean;
|
||||
}
|
||||
|
||||
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
|
||||
|
||||
@@ -65,6 +65,13 @@ describe('cardLooks', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-003b: a file kept from the chat opens the trip Files and shows nothing on the map', () => {
|
||||
const kept: AssistantApplied = { toolCallId: 'k', entity: 'file', ids: [], dayId: null, undoUntil: null };
|
||||
expect(ENTITY_LOOK.file.open?.(kept, null)).toEqual({ kind: 'tab', tab: 'dateien' });
|
||||
expect(ENTITY_LOOK.file.onMap).toBeUndefined();
|
||||
expect(ENTITY_LOOK.file.openLabelKey).toBe('assistant.card.file.open');
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
|
||||
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
|
||||
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
CalendarMinus,
|
||||
CalendarPlus,
|
||||
Circle,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FolderInput,
|
||||
ListTodo,
|
||||
Luggage,
|
||||
MapPin,
|
||||
@@ -63,6 +66,8 @@ export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
|
||||
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
|
||||
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
|
||||
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
|
||||
readAttachment: { Icon: FileSearch, tone: 'info', labelKey: 'assistant.change.verb.readAttachment' },
|
||||
keepFile: { Icon: FolderInput, tone: 'success', labelKey: 'assistant.change.verb.keepFile' },
|
||||
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
|
||||
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
|
||||
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
|
||||
@@ -159,6 +164,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||
},
|
||||
search_help: { stepKey: 'assistant.step.help' },
|
||||
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
|
||||
read_attached_file: { stepKey: 'assistant.step.readingAttachment' },
|
||||
...TOUR_LOOK.steps,
|
||||
...ROADTRIP_LOOK.steps,
|
||||
...BUDGET_LOOK.steps,
|
||||
@@ -193,6 +199,8 @@ export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook
|
||||
assignment: { Icon: CalendarDays, onMap: BY_DAY },
|
||||
day_note: { Icon: StickyNote, onMap: BY_DAY },
|
||||
memory: { Icon: NotebookPen },
|
||||
// A file kept from the chat: it opens the trip's Files.
|
||||
file: { Icon: FileText, openLabelKey: 'assistant.card.file.open', open: () => ({ kind: 'tab', tab: 'dateien' }) },
|
||||
other: { Icon: Circle },
|
||||
...TOUR_LOOK.entities,
|
||||
...ROADTRIP_LOOK.entities,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ArrowRight, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type AssistantChatTrip, useAssistantView } from '../../assistantViewContext';
|
||||
|
||||
/**
|
||||
* What the home chat's cards that go on in another chat share: the question
|
||||
* handed over, waiting there ready to send, and the button that switches to
|
||||
* that chat. The button shows only where a chat can be switched to.
|
||||
*/
|
||||
|
||||
/** The question that waits in the other chat's composer. */
|
||||
export function HandedQuestion({ question }: { question: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!question) return null;
|
||||
return (
|
||||
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
||||
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
||||
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
||||
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The button that goes on in the chat under `chat`, with the question handed over. */
|
||||
export function OpenChatButton({
|
||||
chat,
|
||||
question,
|
||||
label,
|
||||
}: {
|
||||
chat: AssistantChatTrip;
|
||||
question: string | null;
|
||||
label: string;
|
||||
}) {
|
||||
const { onOpenTripChat, variant } = useAssistantView();
|
||||
if (!onOpenTripChat) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTripChat(chat, question)}
|
||||
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
||||
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* FE-AI-JOURNALCHAT-001 to FE-AI-JOURNALCHAT-003
|
||||
*
|
||||
* The home chat's card that offers a journal's own chat: the journal with
|
||||
* its subtitle or days and what it holds, the question waiting there, and the
|
||||
* button that goes on in the chat kept under the journey's key.
|
||||
*/
|
||||
import type { JournalChatCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
import { JournalChatBlock } from './JournalChatBlock';
|
||||
|
||||
const JAPAN: JournalChatCard = {
|
||||
kind: 'journalChat',
|
||||
journey: {
|
||||
id: 12,
|
||||
title: 'Japan 2026',
|
||||
subtitle: null,
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-16',
|
||||
entryCount: 4,
|
||||
photoCount: 120,
|
||||
},
|
||||
question: 'Make a new entry for Berlin Hauptbahnhof',
|
||||
};
|
||||
|
||||
function drawn(
|
||||
cards: JournalChatCard[],
|
||||
onOpenTripChat?: (chat: { id: number; title: string }, q: string | null) => void
|
||||
) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: 0, variant: 'fullscreen', onOpenTripChat }}>
|
||||
<JournalChatBlock cards={cards.map((card, index) => ({ toolCallId: `j${index}`, card }))} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('JournalChatBlock', () => {
|
||||
it('FE-AI-JOURNALCHAT-001: shows the journal, its days, what it holds and the question', () => {
|
||||
drawn([JAPAN]);
|
||||
expect(screen.getByRole('region', { name: 'Continue in a journal' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 entries')).toBeInTheDocument();
|
||||
expect(screen.getByText('120 photos')).toBeInTheDocument();
|
||||
expect(screen.getByText('Make a new entry for Berlin Hauptbahnhof')).toBeInTheDocument();
|
||||
// Nowhere to switch to: no button.
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-JOURNALCHAT-002: the button goes on in the journal's chat, under the journey's key", () => {
|
||||
const open = vi.fn();
|
||||
drawn([JAPAN], open);
|
||||
fireEvent.click(screen.getByRole('button', { name: "Continue in this journal's chat" }));
|
||||
expect(open).toHaveBeenCalledWith({ id: -12, title: 'Japan 2026' }, 'Make a new entry for Berlin Hauptbahnhof');
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNALCHAT-003: a subtitle stands for the days, an empty journal shows no photos, nothing draws nothing', () => {
|
||||
const bare: JournalChatCard = {
|
||||
...JAPAN,
|
||||
journey: { ...JAPAN.journey, subtitle: 'Autumn', startDate: null, endDate: null, entryCount: 0, photoCount: 0 },
|
||||
question: null,
|
||||
};
|
||||
drawn([bare]);
|
||||
expect(screen.getByText('Autumn')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 entries')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/photos?$/)).not.toBeInTheDocument();
|
||||
const { container } = render(<JournalChatBlock cards={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { JournalChatCard } from '@trek/shared';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { assistantChatKey } from '../../../../store/assistantChatKey';
|
||||
import { WhiteBadge } from '../../../Planner/planParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { chatDates } from './chatDates';
|
||||
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
|
||||
|
||||
/**
|
||||
* The home chat's way onto a journal's own page: the journal under its head
|
||||
* (its subtitle or days, how many entries and photos it holds), the question
|
||||
* that waits there ready to send, and the button that goes on in the
|
||||
* journal's chat. That chat is the journey's conversation on its page, kept
|
||||
* under the journey's key; the window switches to it in place, the phone
|
||||
* opens the journal with its chat.
|
||||
*/
|
||||
|
||||
function JournalFacts({ card }: { card: JournalChatCard }) {
|
||||
const { t } = useTranslation();
|
||||
const { entryCount, photoCount } = card.journey;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<WhiteBadge>{t('assistant.card.journey.entries', { count: entryCount })}</WhiteBadge>
|
||||
{photoCount > 0 && <WhiteBadge>{t('assistant.card.journey.photos', { count: photoCount })}</WhiteBadge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JournalChatSection({ card, first }: { card: JournalChatCard; first: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const { journey, question } = card;
|
||||
const meta = journey.subtitle ?? chatDates(t, format.date, journey.startDate, journey.endDate);
|
||||
const chat = { id: assistantChatKey({ kind: 'journey', journeyId: journey.id }), title: journey.title };
|
||||
return (
|
||||
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||
{first ? (
|
||||
<CardHeader
|
||||
icon={BookOpen}
|
||||
tone="info"
|
||||
eyebrow={t('assistant.card.journalChat.eyebrow')}
|
||||
title={journey.title}
|
||||
meta={meta}
|
||||
>
|
||||
<JournalFacts card={card} />
|
||||
</CardHeader>
|
||||
) : (
|
||||
<div className="mb-2.5">
|
||||
<bdi className="block truncate text-body font-bold text-content">{journey.title}</bdi>
|
||||
<div className="mt-0.5 text-caption text-content-muted">{meta}</div>
|
||||
<JournalFacts card={card} />
|
||||
</div>
|
||||
)}
|
||||
<HandedQuestion question={question} />
|
||||
<OpenChatButton chat={chat} question={question} label={t('assistant.card.journalChat.open')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JournalChatBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const journals = cards.flatMap((entry) => (entry.card.kind === 'journalChat' ? [entry.card] : []));
|
||||
if (journals.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.journalChat.label')}>
|
||||
{journals.map((card, index) => (
|
||||
<JournalChatSection key={`${card.journey.id}-${index}`} card={card} first={index === 0} />
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { TripChatCard, TripChatPhase } from '@trek/shared';
|
||||
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
|
||||
import { Luggage } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { chatDates } from './chatDates';
|
||||
import { HandedQuestion, OpenChatButton } from './ChatHandOver';
|
||||
|
||||
/**
|
||||
* The home chat's way into one trip's own chat: the trip under its head (its
|
||||
@@ -27,18 +28,9 @@ const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: stri
|
||||
|
||||
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { onOpenTripChat, variant } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
const { trip, question } = card;
|
||||
const start = trip.startDate ?? trip.endDate;
|
||||
const end = trip.endDate ?? trip.startDate;
|
||||
let dates = t('assistant.card.tripChat.noDates');
|
||||
if (start && end) {
|
||||
dates =
|
||||
start === end
|
||||
? format.date(start)
|
||||
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
|
||||
}
|
||||
const dates = chatDates(t, format.date, trip.startDate, trip.endDate);
|
||||
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
|
||||
return (
|
||||
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||
@@ -59,25 +51,12 @@ function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }
|
||||
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
||||
</div>
|
||||
)}
|
||||
{question && (
|
||||
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
||||
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
||||
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
||||
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
||||
</p>
|
||||
)}
|
||||
{onOpenTripChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
|
||||
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
||||
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
||||
}`}
|
||||
>
|
||||
{t('assistant.card.tripChat.open')}
|
||||
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
<HandedQuestion question={question} />
|
||||
<OpenChatButton
|
||||
chat={{ id: trip.id, title: trip.title }}
|
||||
question={question}
|
||||
label={t('assistant.card.tripChat.open')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The days a card of another chat names under its head: one day, a span, or
|
||||
* that it has none yet. Pure, so the trip's card and the journal's read one
|
||||
* wording.
|
||||
*/
|
||||
export function chatDates(
|
||||
t: (key: string, params?: Record<string, string>) => string,
|
||||
date: (value: string) => string,
|
||||
startDate: string | null,
|
||||
endDate: string | null
|
||||
): string {
|
||||
const start = startDate ?? endDate;
|
||||
const end = endDate ?? startDate;
|
||||
if (!start || !end) return t('assistant.card.tripChat.noDates');
|
||||
if (start === end) return date(start);
|
||||
return t('assistant.card.tripChat.dates', { start: date(start), end: date(end) });
|
||||
}
|
||||
@@ -13,9 +13,11 @@ export const HOME_LOOK = {
|
||||
steps: {
|
||||
list_trips: { stepKey: 'assistant.step.readingTrips' },
|
||||
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
|
||||
open_journey_chat: { stepKey: 'assistant.step.openJournalChat' },
|
||||
},
|
||||
entities: {},
|
||||
cards: {
|
||||
tripChat: {},
|
||||
journalChat: {},
|
||||
},
|
||||
} satisfies PackLook<'tripChat'>;
|
||||
} satisfies PackLook<'tripChat' | 'journalChat'>;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { CardView } from '../cardTypes';
|
||||
import { JournalChatBlock } from './JournalChatBlock';
|
||||
import { TripChatBlock } from './TripChatBlock';
|
||||
|
||||
/**
|
||||
* How the home chat's kind draws: the trip it offers to go on with, beside
|
||||
* the answer. This file only maps the kind to its view; the component lives
|
||||
* How the home chat's kinds draw: the trip or the journal it offers to go on
|
||||
* with, beside the answer. This file only maps the kind to its view; the component lives
|
||||
* in a sibling file, since a file that exports anything besides components
|
||||
* breaks fast refresh.
|
||||
*/
|
||||
export const HOME_VIEWS = {
|
||||
tripChat: { Block: TripChatBlock },
|
||||
} satisfies { [K in 'tripChat']: CardView<K> };
|
||||
journalChat: { Block: JournalChatBlock },
|
||||
} satisfies { [K in 'tripChat' | 'journalChat']: CardView<K> };
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { AssistantChangeRow, JourneyEntriesCard } from '@trek/shared';
|
||||
import { AlignLeft, CalendarDays, Clock, MapPin, Tag, Type } from 'lucide-react';
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowDownUp,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
LocateFixed,
|
||||
MapPin,
|
||||
Tag,
|
||||
Tags,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
import { EntryMarks, EntryStory, EntryThumb } from './JourneyEntryParts';
|
||||
@@ -20,7 +33,9 @@ import { entryHoverProps, useEntryHover } from './useEntryHover';
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
|
||||
/** The facts a journal change row names on its faint line, in this order: when, then where. */
|
||||
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin } as const;
|
||||
const FACT_ICONS = { day: CalendarDays, time: Clock, location: MapPin, coordinates: LocateFixed } as const;
|
||||
/** The lists an update rewrites, each by its glyph: the tags and the two sides of the verdict. */
|
||||
const LIST_ICONS: Readonly<Record<string, typeof Tag>> = { tags: Tags, pros: ThumbsUp, cons: ThumbsDown };
|
||||
/** The words an update rewrites, named only when they change: the entry's title and its text. */
|
||||
const WORD_ICONS = { name: Type, text: AlignLeft } as const;
|
||||
type FactRow = AssistantChangeRow & { labelKey: keyof typeof FACT_ICONS };
|
||||
@@ -45,7 +60,9 @@ function StateChange({ row }: { row: AssistantChangeRow }) {
|
||||
if (flag) return <FactChange icon={flag.Icon} before={null} after={t(flag.labelKey)} tone="info" />;
|
||||
const was = stateOf(row.field, row.before);
|
||||
const now = stateOf(row.field, row.after);
|
||||
const Icon = (now ?? was)?.Icon ?? Tag;
|
||||
const listed =
|
||||
row.field && Object.prototype.hasOwnProperty.call(LIST_ICONS, row.field) ? LIST_ICONS[row.field] : undefined;
|
||||
const Icon = (now ?? was)?.Icon ?? listed ?? Tag;
|
||||
const named = (tone: typeof now, value: string | null) => (tone ? t(tone.labelKey) : value);
|
||||
return (
|
||||
<FactChange
|
||||
@@ -110,12 +127,32 @@ export function JourneyEntryAside({ card, item }: { card: JourneyEntriesCard; it
|
||||
);
|
||||
}
|
||||
|
||||
/** A new order: the entries that move, in their new places, each with its day; the rest counted. */
|
||||
function NewOrder({ card }: { card: JourneyEntriesCard }) {
|
||||
const { t, locale } = useTranslation();
|
||||
return (
|
||||
<ol className="m-0 mt-1 list-none space-y-0.5 p-0">
|
||||
{card.entries.map((look, index) => (
|
||||
<li key={look.entryId ?? index} className="flex items-center gap-1.5 text-caption text-content-secondary">
|
||||
<ArrowDownUp size={11} aria-hidden="true" className="flex-none text-info" />
|
||||
<bdi className="min-w-0 truncate">{look.title ?? look.place ?? cardDateLabel(look.date, locale)}</bdi>
|
||||
<span className="flex-none text-content-faint">{cardDateLabel(look.date, locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
{card.more > 0 && (
|
||||
<li className="text-caption text-content-faint">{t('assistant.card.common.more', { count: card.more })}</li>
|
||||
)}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** The start of the entry's text and its marks, under the row; resting on them shows its place on the map. */
|
||||
export function JourneyEntryBody({ card, item }: { card: JourneyEntriesCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
const hover = useEntryHover(card.journeyId, item.toolCallId);
|
||||
const look = card.entries[0];
|
||||
if (!look || GONE.has(item.state)) return null;
|
||||
if (card.change === 'reordered') return <NewOrder card={card} />;
|
||||
return (
|
||||
<div {...entryHoverProps(hover, look)}>
|
||||
<EntryStory look={look} roomy={false} />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* FE-AI-JOURNEY-020 to FE-AI-JOURNEY-022
|
||||
*
|
||||
* The parts of a journal entry the chat added later: its tags and how its
|
||||
* verdict leans among its marks, an update's tags, verdict and coordinates
|
||||
* old to new, and a new order of the entries drawn as the entries that move.
|
||||
*/
|
||||
import type { AssistantChangePreview, JourneyEntriesCard, JourneyEntryLook } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../../../tests/helpers/render';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
|
||||
const ENTRY: JourneyEntryLook = {
|
||||
entryId: 12,
|
||||
type: 'entry',
|
||||
title: 'Sunset at the Spree',
|
||||
date: '2026-10-21',
|
||||
time: null,
|
||||
place: null,
|
||||
point: null,
|
||||
countryCode: null,
|
||||
story: null,
|
||||
mood: null,
|
||||
weather: null,
|
||||
photoId: null,
|
||||
video: false,
|
||||
photoCount: 0,
|
||||
visibility: 'private',
|
||||
draft: false,
|
||||
fromTripPlace: false,
|
||||
dayIndex: null,
|
||||
tags: ['river', 'evening'],
|
||||
verdict: { pros: ['the light'], cons: ['cold', 'wind'], prosCount: 1, consCount: 2 },
|
||||
};
|
||||
|
||||
function journal(entries: JourneyEntryLook[], change: JourneyEntriesCard['change'], more = 0): JourneyEntriesCard {
|
||||
return { kind: 'journeyEntries', journeyId: 3, journalTitle: 'Berlin', tripJournal: false, change, entries, more };
|
||||
}
|
||||
|
||||
function pending(
|
||||
card: JourneyEntriesCard,
|
||||
verbKey: AssistantChangePreview['verbKey'],
|
||||
rows: AssistantChangePreview['rows']
|
||||
) {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'e1',
|
||||
verbKey,
|
||||
subject: 'Berlin',
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
domain: 'journey',
|
||||
card,
|
||||
};
|
||||
const item: ChangeItem = { toolCallId: 'e1', preview, applied: null, state: 'pending', card };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('the later parts of a journal entry', () => {
|
||||
it('FE-AI-JOURNEY-020: a new entry shows its tags and how its verdict leans', () => {
|
||||
pending(journal([ENTRY], 'new'), 'addJournalEntry', []);
|
||||
expect(screen.getByText('river')).toBeInTheDocument();
|
||||
expect(screen.getByText('evening')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 good point')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 weak points')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEY-021: an update names its tags, each side of its verdict and its coordinates, old to new', () => {
|
||||
const rows: AssistantChangePreview['rows'] = [
|
||||
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
|
||||
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
|
||||
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
|
||||
];
|
||||
pending(journal([{ ...ENTRY, tags: [], verdict: null }], 'changed'), 'updateJournalEntry', rows);
|
||||
expect(screen.getByText('river, evening')).toHaveClass('line-through');
|
||||
expect(screen.getByText('cold')).toBeInTheDocument();
|
||||
expect(screen.getByText('52.5200, 13.4050')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEY-022: a new order lists the entries that move in their new places, the rest counted', () => {
|
||||
const moved = [
|
||||
{ ...ENTRY, entryId: 2, title: 'Museum Island', moved: true },
|
||||
{ ...ENTRY, entryId: 1, title: 'Arrival', moved: true },
|
||||
];
|
||||
pending(journal(moved, 'reordered', 3), 'reorderJournalEntries', []);
|
||||
expect(screen.getByText('Put the entries in a new order')).toBeInTheDocument();
|
||||
const text = document.body.textContent ?? '';
|
||||
expect(text.indexOf('Museum Island')).toBeGreaterThan(-1);
|
||||
expect(text.indexOf('Museum Island')).toBeLessThan(text.indexOf('Arrival'));
|
||||
expect(screen.getByText('3 more')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JourneyEntryLook } from '@trek/shared';
|
||||
import { CalendarDays, Globe, Images, MapPin, Play, Users } from 'lucide-react';
|
||||
import { CalendarDays, Globe, Images, MapPin, Play, Tag, ThumbsDown, ThumbsUp, Users } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel, cardPlace } from '../../../Journey/journeyCard';
|
||||
@@ -101,11 +101,42 @@ function WeatherMark({ weather }: { weather: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first. */
|
||||
/** Its tags and how its verdict leans: the good and the weak points counted. */
|
||||
function ListMarks({ look }: { look: JourneyEntryLook }) {
|
||||
const { t } = useTranslation();
|
||||
const verdict = look.verdict ?? null;
|
||||
return (
|
||||
<>
|
||||
{(look.tags ?? []).map((tag, index) => (
|
||||
<SoftPill
|
||||
key={`${tag}-${index}`}
|
||||
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
|
||||
>
|
||||
<bdi className="truncate">{tag}</bdi>
|
||||
</SoftPill>
|
||||
))}
|
||||
{verdict && verdict.prosCount > 0 && (
|
||||
<SoftPill tone="success" icon={<ThumbsUp size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.journey.pros', { count: verdict.prosCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
{verdict && verdict.consCount > 0 && (
|
||||
<SoftPill tone="warning" icon={<ThumbsDown size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.journey.cons', { count: verdict.consCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** How the entry reads: shared or public, a draft, its mood and weather, its photos past the first, its tags and verdict. */
|
||||
export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
||||
const { t } = useTranslation();
|
||||
const mood = moodOf(look.mood) !== null;
|
||||
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2) return null;
|
||||
const lists = (look.tags?.length ?? 0) > 0 || !!look.verdict;
|
||||
if (look.visibility === 'private' && !look.draft && !mood && !look.weather && look.photoCount < 2 && !lists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CardFacts>
|
||||
<VisibilityMark visibility={look.visibility} />
|
||||
@@ -117,6 +148,7 @@ export function EntryMarks({ look }: { look: JourneyEntryLook }) {
|
||||
{t('assistant.card.journey.photos', { count: look.photoCount })}
|
||||
</SoftPill>
|
||||
)}
|
||||
<ListMarks look={look} />
|
||||
</CardFacts>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* FE-AI-JOURNEYHEADER-001 to FE-AI-JOURNEYHEADER-005
|
||||
*
|
||||
* A change of a journal itself in the chat: the settings an update sets by
|
||||
* the journal's own names, old to new; the trips under the row with the one
|
||||
* the change links or unlinks marked; what a new journal brings and what a
|
||||
* deletion or an unlink takes; the receipt that opens the journal.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangePreview, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../../store/assistantStore';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
import { headerValueKey, settingRows } from './journeyHeaderModel';
|
||||
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
});
|
||||
|
||||
const TOKYO: JourneyHeaderTrip = {
|
||||
id: 5,
|
||||
title: 'Tokyo days',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-08',
|
||||
change: 'kept',
|
||||
};
|
||||
|
||||
const JAPAN: JourneyHeaderCard = {
|
||||
kind: 'journeyHeader',
|
||||
journeyId: 12,
|
||||
title: 'Japan 2026',
|
||||
subtitle: null,
|
||||
change: 'changed',
|
||||
trips: [TOKYO],
|
||||
entries: 3,
|
||||
photos: 40,
|
||||
suggestions: null,
|
||||
};
|
||||
|
||||
function change(
|
||||
state: ChangeItem['state'],
|
||||
card: JourneyHeaderCard,
|
||||
over: Partial<AssistantChangePreview> = {}
|
||||
): ChangeItem {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'h1',
|
||||
verbKey: 'updateJournalSettings',
|
||||
subject: 'Japan 2026',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
domain: 'journey',
|
||||
card,
|
||||
...over,
|
||||
};
|
||||
const applied: AssistantApplied = { toolCallId: 'h1', entity: 'journey', ids: [12], dayId: null, undoUntil: null };
|
||||
return { toolCallId: 'h1', preview, applied: state === 'applied' ? applied : null, state, card };
|
||||
}
|
||||
|
||||
function shown(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -12, variant: 'window', onOpen }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('a change of a journal itself', () => {
|
||||
it('FE-AI-JOURNEYHEADER-001: an update names each setting by the journal own words, old to new', () => {
|
||||
const rows: AssistantChangePreview['rows'] = [
|
||||
{ op: 'change', labelKey: 'name', before: 'Japan', after: 'Japan 2026' },
|
||||
{ op: 'change', labelKey: 'field', field: 'show_mood', before: '✓', after: '✗' },
|
||||
{ op: 'change', labelKey: 'field', field: 'status_override', before: '', after: 'live' },
|
||||
];
|
||||
shown(change('pending', JAPAN, { rows }));
|
||||
expect(screen.getByText("Change the journal's settings")).toBeInTheDocument();
|
||||
expect(screen.getByText('Japan')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Mood: On')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Mood: Off')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shown as: Automatic')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Shown as: Live')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo days')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-002: a new journal shows its subtitle, the trips it links and the places that come in', () => {
|
||||
const fresh: JourneyHeaderCard = {
|
||||
...JAPAN,
|
||||
journeyId: null,
|
||||
change: 'new',
|
||||
subtitle: 'Autumn',
|
||||
trips: [{ ...TOKYO, change: 'link' }],
|
||||
entries: null,
|
||||
photos: null,
|
||||
suggestions: 9,
|
||||
};
|
||||
shown(change('pending', fresh, { verbKey: 'createJournal', undoable: true }));
|
||||
expect(screen.getByText('Start a journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Autumn')).toBeInTheDocument();
|
||||
expect(screen.getByText('Will be added')).toBeInTheDocument();
|
||||
expect(screen.getByText('9 places come in as suggestions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-003: a deletion warns that everything goes; an unlink says what stays and what goes', () => {
|
||||
const first = shown(change('pending', { ...JAPAN, change: 'willDelete' }, { verbKey: 'deleteJournal' }));
|
||||
expect(screen.getByText('Delete the journal')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This cannot be undone\./)).toBeInTheDocument();
|
||||
first.unmount();
|
||||
const unlink: JourneyHeaderCard = {
|
||||
...JAPAN,
|
||||
trips: [{ ...TOKYO, change: 'unlink' }],
|
||||
entries: 1,
|
||||
suggestions: 4,
|
||||
};
|
||||
shown(change('pending', unlink, { verbKey: 'unlinkJournalTrip' }));
|
||||
expect(screen.getByText('Tokyo days')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Will be taken out')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 written entry stays')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 open suggestions go')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-004: the receipt opens the journal, a change that failed shows its row alone', () => {
|
||||
const onOpen = vi.fn();
|
||||
const applied = shown(change('applied', JAPAN), onOpen);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open the journal' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/12' });
|
||||
applied.unmount();
|
||||
shown(change('failed', JAPAN));
|
||||
expect(screen.queryByText('Tokyo days')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYHEADER-005: the model reads a status by its name and a switch as on or off', () => {
|
||||
expect(headerValueKey('status', 'archived')).toBe('journey.status.archived');
|
||||
expect(headerValueKey('status_override', null)).toBe('journey.settings.statusAuto');
|
||||
expect(headerValueKey('show_mood', '✓')).toBe('settings.on');
|
||||
expect(headerValueKey('cover_gradient', 'linear-gradient(red, blue)')).toBeNull();
|
||||
expect(headerValueKey('status', 'toString')).toBeNull();
|
||||
const rows: AssistantChangePreview['rows'] = [
|
||||
{ op: 'add', labelKey: 'field', field: 'trip', before: null, after: 'Kyoto' },
|
||||
{ op: 'add', labelKey: 'field', field: 'show_weather', before: null, after: '✓' },
|
||||
{ op: 'add', labelKey: 'field', field: 'unknown', before: null, after: 'x' },
|
||||
{ op: 'change', labelKey: 'name', before: 'A', after: 'B' },
|
||||
];
|
||||
expect(settingRows(rows).map((row) => row.field)).toEqual(['show_weather']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { AssistantChangeRow, JourneyHeaderCard, JourneyHeaderTrip } from '@trek/shared';
|
||||
import { AlignLeft, Luggage, Type } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type PillTone, SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { headerFieldOf, headerValueKey, settingRows } from './journeyHeaderModel';
|
||||
|
||||
/**
|
||||
* What a change row of a journal itself adds to the row it already is (its
|
||||
* verb and the journal's name): on the faint line the name, subtitle and
|
||||
* settings it changes, each old to new; under the row the journal's trips,
|
||||
* the one the change links or unlinks marked, and what the change brings or
|
||||
* takes along. A change that failed, was discarded or ran out shows the row
|
||||
* alone.
|
||||
*/
|
||||
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
const WORD_ICONS = { name: Type, description: AlignLeft } as const;
|
||||
const NOTE = 'm-0 mt-1.5 text-caption';
|
||||
|
||||
const TRIP_TONE: Readonly<Record<JourneyHeaderTrip['change'], PillTone>> = {
|
||||
kept: 'neutral',
|
||||
link: 'success',
|
||||
unlink: 'danger',
|
||||
};
|
||||
|
||||
/** One setting a change sets, by its name, old to new. */
|
||||
function SettingChange({ row }: { row: AssistantChangeRow }) {
|
||||
const { t } = useTranslation();
|
||||
const look = headerFieldOf(row.field);
|
||||
if (!look) return null;
|
||||
const named = (value: string | null) => {
|
||||
const key = headerValueKey(row.field, value);
|
||||
if (key) return t(key);
|
||||
return value || null;
|
||||
};
|
||||
const before = row.op === 'add' ? null : named(row.before);
|
||||
const after = named(row.after) ?? t('assistant.card.common.removed');
|
||||
const name = t(look.labelKey);
|
||||
return (
|
||||
<FactChange
|
||||
icon={look.Icon}
|
||||
before={before === null ? null : t('assistant.card.journeyHeader.setting', { name, value: before })}
|
||||
after={t('assistant.card.journeyHeader.setting', { name, value: after })}
|
||||
tone="info"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The journal's new name and subtitle and the settings a change sets. */
|
||||
export function JourneyHeaderFacts({ item }: { card: JourneyHeaderCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = item.preview?.rows ?? [];
|
||||
const words = rows.filter(
|
||||
(row): row is AssistantChangeRow & { labelKey: keyof typeof WORD_ICONS } =>
|
||||
row.op === 'change' && row.labelKey in WORD_ICONS
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{words.map((row) => (
|
||||
<FactChange
|
||||
key={row.labelKey}
|
||||
icon={WORD_ICONS[row.labelKey]}
|
||||
before={row.before || null}
|
||||
after={row.after || t('assistant.card.common.removed')}
|
||||
tone="info"
|
||||
/>
|
||||
))}
|
||||
{settingRows(rows).map((row) => (
|
||||
<SettingChange key={row.field} row={row} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TripChip({ trip }: { trip: JourneyHeaderTrip }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const days = trip.startDate ? format.date(trip.startDate) : null;
|
||||
return (
|
||||
<SoftPill
|
||||
tone={TRIP_TONE[trip.change]}
|
||||
className="max-w-full"
|
||||
icon={<Luggage size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
|
||||
>
|
||||
{trip.change === 'link' && <span className="sr-only">{t('assistant.card.journeyHeader.tripLink')}</span>}
|
||||
{trip.change === 'unlink' && <span className="sr-only">{t('assistant.card.journeyHeader.tripUnlink')}</span>}
|
||||
<bdi className={`truncate ${trip.change === 'unlink' ? 'line-through' : ''}`}>{trip.title}</bdi>
|
||||
{days && <span className="text-content-faint">{days}</span>}
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the change brings along or takes with it, in the journal's words. */
|
||||
function HeaderNotes({ card }: { card: JourneyHeaderCard }) {
|
||||
const { t } = useTranslation();
|
||||
const unlinking = card.trips.some((trip) => trip.change === 'unlink');
|
||||
if (card.change === 'willDelete') {
|
||||
return <p className={`${NOTE} text-danger`}>{t('assistant.card.journeyHeader.deletes')}</p>;
|
||||
}
|
||||
if (unlinking) {
|
||||
return (
|
||||
<>
|
||||
{card.entries !== null && card.entries > 0 && (
|
||||
<p className={`${NOTE} text-content-muted`}>
|
||||
{t('assistant.card.journeyHeader.entriesStay', { count: card.entries })}
|
||||
</p>
|
||||
)}
|
||||
{card.suggestions !== null && card.suggestions > 0 && (
|
||||
<p className={`${NOTE} text-content-muted`}>
|
||||
{t('assistant.card.journeyHeader.suggestionsGo', { count: card.suggestions })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (card.suggestions === null || card.suggestions === 0) return null;
|
||||
return (
|
||||
<p className={`${NOTE} text-content-muted`}>
|
||||
{t('assistant.card.journeyHeader.suggestionsCome', { count: card.suggestions })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The journal's trips and what the change brings or takes, under the row. */
|
||||
export function JourneyHeaderBody({ card, item }: { card: JourneyHeaderCard; item: ChangeItem }) {
|
||||
if (GONE.has(item.state)) return null;
|
||||
return (
|
||||
<div>
|
||||
{card.subtitle && card.change === 'new' && (
|
||||
<p className="m-0 text-caption text-content-muted">
|
||||
<bdi>{card.subtitle}</bdi>
|
||||
</p>
|
||||
)}
|
||||
{card.trips.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{card.trips.map((trip, index) => (
|
||||
<TripChip key={`${trip.id ?? 'x'}-${index}`} trip={trip} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<HeaderNotes card={card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
|
||||
*
|
||||
* A change of a journal's photos in the chat: the entries the photos leave
|
||||
* and reach, the photos as thumbnails, a caption old to new, what stays
|
||||
* where, and the receipt that opens the entry.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
|
||||
const MOVE: JourneyPhotosCard = {
|
||||
kind: 'journeyPhotos',
|
||||
journeyId: 3,
|
||||
mode: 'move',
|
||||
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
|
||||
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
|
||||
photos: [
|
||||
{ photoId: 101, video: false, caption: 'Black sand' },
|
||||
{ photoId: 102, video: true, caption: null },
|
||||
],
|
||||
more: 4,
|
||||
kept: false,
|
||||
caption: null,
|
||||
};
|
||||
|
||||
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'p1',
|
||||
verbKey,
|
||||
subject: 'Reynisfjara',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: false,
|
||||
domain: 'journey',
|
||||
card,
|
||||
};
|
||||
const receipt: AssistantApplied = {
|
||||
toolCallId: 'p1',
|
||||
entity: 'journey_entry',
|
||||
ids: [2],
|
||||
dayId: null,
|
||||
undoUntil: null,
|
||||
};
|
||||
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
|
||||
}
|
||||
|
||||
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('a change of a journal photos', () => {
|
||||
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
|
||||
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vik')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
|
||||
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
||||
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
|
||||
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
|
||||
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
|
||||
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
|
||||
expect(screen.getByText('Entry cover')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
|
||||
const caption = {
|
||||
...MOVE,
|
||||
mode: 'caption' as const,
|
||||
from: null,
|
||||
to: null,
|
||||
more: 0,
|
||||
photos: [{ photoId: 101, video: false, caption: 'Old' }],
|
||||
};
|
||||
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Old')).toHaveClass('line-through');
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Removed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
|
||||
const onOpen = vi.fn();
|
||||
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
|
||||
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
|
||||
/**
|
||||
* What a change row of a journal's photos adds under the row: the entries
|
||||
* the photos leave and reach, the photos themselves as small thumbnails (the
|
||||
* first marked as the entry's cover in a new order), a caption old to new,
|
||||
* and what the change leaves where. A change that failed, was discarded or
|
||||
* ran out shows the row alone.
|
||||
*/
|
||||
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
|
||||
|
||||
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
|
||||
const { locale } = useTranslation();
|
||||
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
|
||||
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
|
||||
}
|
||||
|
||||
/** The entries the photos leave and reach, the way between them marked. */
|
||||
function Route({ card }: { card: JourneyPhotosCard }) {
|
||||
if (!card.from && !card.to) return null;
|
||||
return (
|
||||
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
|
||||
{card.from && <EntryName entry={card.from} />}
|
||||
{card.from && card.to && (
|
||||
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
)}
|
||||
{card.to && <EntryName entry={card.to} />}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbs({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.photos.length === 0 && card.more === 0) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.photos.map((photo, index) => (
|
||||
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
|
||||
<img
|
||||
src={`/api/photos/${photo.photoId}/thumbnail`}
|
||||
alt={photo.caption ?? ''}
|
||||
loading="lazy"
|
||||
className="size-full rounded-lg bg-surface-secondary object-cover"
|
||||
/>
|
||||
{photo.video && (
|
||||
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
|
||||
<span className="sr-only">{t('assistant.card.journey.video')}</span>
|
||||
</SoftPill>
|
||||
)}
|
||||
{card.mode === 'order' && index === 0 && (
|
||||
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{card.more > 0 && (
|
||||
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.common.more', { count: card.more })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
|
||||
function Notes({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
|
||||
if (card.mode === 'unlink' || card.mode === 'move') {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
|
||||
}
|
||||
if (card.mode === 'order' && card.photos.length > 0) {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
|
||||
}
|
||||
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
if (GONE.has(item.state)) return null;
|
||||
return (
|
||||
<div>
|
||||
<Route card={card} />
|
||||
<Thumbs card={card} />
|
||||
{card.caption && (
|
||||
<span className="mt-1.5 flex">
|
||||
<FactChange
|
||||
icon={MessageSquareText}
|
||||
before={card.caption.before}
|
||||
after={card.caption.after ?? t('assistant.card.common.removed')}
|
||||
tone="info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<Notes card={card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AssistantChangeRow } from '@trek/shared';
|
||||
import {
|
||||
CircleDot,
|
||||
CloudSun,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Luggage,
|
||||
MapPin,
|
||||
Palette,
|
||||
Route,
|
||||
Smile,
|
||||
Sparkles,
|
||||
ThumbsUp,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The pure side of a change of a journal itself: the glyph and name of each
|
||||
* setting a change row names (under the tool's own field names), and how its
|
||||
* value reads, a status by its name, a switch as on or off, a cleared status
|
||||
* override as following the trip dates. The names are the journal's own,
|
||||
* as its settings dialog writes them.
|
||||
*/
|
||||
|
||||
interface FieldLook {
|
||||
Icon: LucideIcon;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const FIELDS: Readonly<Record<string, FieldLook>> = {
|
||||
status: { Icon: CircleDot, labelKey: 'journey.settings.status' },
|
||||
status_override: { Icon: Eye, labelKey: 'assistant.card.journeyHeader.field.statusOverride' },
|
||||
show_verdict: { Icon: ThumbsUp, labelKey: 'journey.settings.showVerdict' },
|
||||
show_mood: { Icon: Smile, labelKey: 'journey.settings.showMood' },
|
||||
show_weather: { Icon: CloudSun, labelKey: 'journey.settings.showWeather' },
|
||||
photo_location: { Icon: MapPin, labelKey: 'journey.settings.photoLocation' },
|
||||
show_trip_tracks: { Icon: Route, labelKey: 'journey.settings.showTripTracks' },
|
||||
auto_prepare_recordings: { Icon: Footprints, labelKey: 'journey.settings.autoPrepare' },
|
||||
cover_gradient: { Icon: Palette, labelKey: 'assistant.card.journeyHeader.field.coverGradient' },
|
||||
trip: { Icon: Luggage, labelKey: 'assistant.card.journeyHeader.field.trip' },
|
||||
suggestions: { Icon: Sparkles, labelKey: 'assistant.card.journeyHeader.field.suggestions' },
|
||||
hide_skeletons: { Icon: EyeOff, labelKey: 'journey.skeletons.hide' },
|
||||
};
|
||||
|
||||
const STATUSES: Readonly<Record<string, string>> = {
|
||||
draft: 'journey.status.draft',
|
||||
active: 'journey.status.active',
|
||||
completed: 'journey.status.completed',
|
||||
archived: 'journey.status.archived',
|
||||
live: 'journey.frontpage.live',
|
||||
};
|
||||
|
||||
const FLAG_ON = '✓';
|
||||
const FLAG_OFF = '✗';
|
||||
|
||||
function own<T>(table: Readonly<Record<string, T>>, key: string | null | undefined): T | null {
|
||||
if (!key || !Object.prototype.hasOwnProperty.call(table, key)) return null;
|
||||
return table[key] ?? null;
|
||||
}
|
||||
|
||||
/** The look of a setting a row names; null for a field the card has no name for. */
|
||||
export function headerFieldOf(field: string | undefined): FieldLook | null {
|
||||
return own(FIELDS, field);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a value of a setting reads by, or null for one that reads as it is
|
||||
* (a name, a count). A cleared status override follows the trip dates again.
|
||||
*/
|
||||
export function headerValueKey(field: string | undefined, value: string | null): string | null {
|
||||
if (value === FLAG_ON) return 'settings.on';
|
||||
if (value === FLAG_OFF) return 'settings.off';
|
||||
if (field === 'status_override' && !value) return 'journey.settings.statusAuto';
|
||||
if (field === 'status' || field === 'status_override') return own(STATUSES, value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The setting rows of a change, the trips aside: the card draws those as its own chips. */
|
||||
export function settingRows(rows: readonly AssistantChangeRow[]): AssistantChangeRow[] {
|
||||
return rows.filter((row) => row.labelKey === 'field' && row.field !== 'trip' && headerFieldOf(row.field) !== null);
|
||||
}
|
||||
@@ -1,4 +1,22 @@
|
||||
import { BookOpen, BookOpenText, BookPlus, BookX } from 'lucide-react';
|
||||
import {
|
||||
ArrowDownUp,
|
||||
ArrowRightLeft,
|
||||
BookCheck,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
BookPlus,
|
||||
BookX,
|
||||
Eye,
|
||||
ImageMinus,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Link,
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Type,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { PackLook } from '../cardTypes';
|
||||
import { journalTarget } from './journeyCardModel';
|
||||
@@ -17,6 +35,34 @@ export const JOURNEY_LOOK = {
|
||||
addJournalEntry: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.addJournalEntry' },
|
||||
updateJournalEntry: { Icon: BookOpenText, tone: 'info', labelKey: 'assistant.change.verb.updateJournalEntry' },
|
||||
removeJournalEntry: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.removeJournalEntry' },
|
||||
reorderJournalEntries: {
|
||||
Icon: ArrowDownUp,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.reorderJournalEntries',
|
||||
},
|
||||
createJournal: { Icon: BookPlus, tone: 'success', labelKey: 'assistant.change.verb.createJournal' },
|
||||
renameJournal: { Icon: Type, tone: 'info', labelKey: 'assistant.change.verb.renameJournal' },
|
||||
setJournalStatus: { Icon: BookCheck, tone: 'info', labelKey: 'assistant.change.verb.setJournalStatus' },
|
||||
updateJournalSettings: { Icon: Settings2, tone: 'info', labelKey: 'assistant.change.verb.updateJournalSettings' },
|
||||
deleteJournal: { Icon: BookX, tone: 'danger', labelKey: 'assistant.change.verb.deleteJournal' },
|
||||
linkJournalTrip: { Icon: Link, tone: 'success', labelKey: 'assistant.change.verb.linkJournalTrip' },
|
||||
unlinkJournalTrip: { Icon: Unlink, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalTrip' },
|
||||
restoreJournalSuggestions: {
|
||||
Icon: RotateCcw,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
|
||||
},
|
||||
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
|
||||
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
|
||||
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
|
||||
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
|
||||
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
|
||||
captionJournalPhoto: {
|
||||
Icon: MessageSquareText,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.captionJournalPhoto',
|
||||
},
|
||||
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
|
||||
},
|
||||
tools: {
|
||||
create_journey: { labelKey: 'assistant.tool.create_journey' },
|
||||
@@ -35,10 +81,21 @@ export const JOURNEY_LOOK = {
|
||||
journey_entry: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journey.open',
|
||||
open: (applied, card) =>
|
||||
card?.kind === 'journeyEntries'
|
||||
open: (applied, card) => {
|
||||
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
|
||||
return card?.kind === 'journeyEntries'
|
||||
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
|
||||
: null,
|
||||
: null;
|
||||
},
|
||||
},
|
||||
journey: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journeyHeader.open',
|
||||
open: (applied, card) => {
|
||||
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
|
||||
const journeyId = applied.ids[0] ?? fromCard;
|
||||
return journeyId ? journalTarget(journeyId, null, false) : null;
|
||||
},
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
@@ -59,5 +116,11 @@ export const JOURNEY_LOOK = {
|
||||
return readIds.length > 0 && readIds.every((id) => id !== null && touched.has(id));
|
||||
},
|
||||
},
|
||||
journeyHeader: {
|
||||
entity: () => 'journey',
|
||||
},
|
||||
journeyPhotos: {
|
||||
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
|
||||
},
|
||||
},
|
||||
} satisfies PackLook<'journeyEntries'>;
|
||||
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { CardView } from '../cardTypes';
|
||||
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
|
||||
import JourneyEntriesBlock from './JourneyEntriesBlock';
|
||||
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
|
||||
import { JourneyPhotosBody } from './JourneyPhotosSlots';
|
||||
|
||||
/**
|
||||
* How the kinds of the journeys draw in the chat: the entries a read found
|
||||
* as a block of their own, and a change row of an entry with its photo beside
|
||||
* the subject, its day, time and place and whatever an update changes on the
|
||||
* faint line, and the start of its text under it. This file only maps each
|
||||
* faint line, and the start of its text under it; a change of the journal
|
||||
* itself with its settings on the faint line and its trips under it. This file only maps each
|
||||
* kind to its view: the components live in sibling files, since a file that
|
||||
* exports anything besides components breaks fast refresh, and lint:warnings holds
|
||||
* react-refresh/only-export-components at zero for a new file.
|
||||
@@ -18,4 +21,11 @@ export const JOURNEY_VIEWS = {
|
||||
Facts: JourneyEntryFacts,
|
||||
Body: JourneyEntryBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries']: CardView<K> };
|
||||
journeyHeader: {
|
||||
Facts: JourneyHeaderFacts,
|
||||
Body: JourneyHeaderBody,
|
||||
},
|
||||
journeyPhotos: {
|
||||
Body: JourneyPhotosBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* FE-AI-HOME-001 to FE-AI-HOME-004
|
||||
* FE-AI-HOME-001 to FE-AI-HOME-006
|
||||
*
|
||||
* The home chat's shared hooks: its key and name, the way into one trip's own
|
||||
* chat with the question waiting there (a question already typed there
|
||||
* stays), the way in from the pages outside a trip, and the questions the
|
||||
* empty home chat offers.
|
||||
* empty home chat offers, and the journal on screen as a chat.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useJourneyStore } from '../../store/journeyStore';
|
||||
import { HOME_SUGGESTIONS } from './assistantModel';
|
||||
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
|
||||
import { chatPagePath, useAssistantHome, useAssistantHomeLauncher, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
@@ -100,3 +101,29 @@ describe('the empty home chat', () => {
|
||||
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
function RouteJournal() {
|
||||
const chat = useRouteJournalChat();
|
||||
return <p>{chat ? `journal ${chat.id} ${chat.title}` : 'no journal'}</p>;
|
||||
}
|
||||
|
||||
describe('the journal on screen', () => {
|
||||
it("FE-AI-HOME-005: a journey's page is its journal's chat, under the journey's key, but not in its Studio", () => {
|
||||
useJourneyStore.setState({ current: { id: 12, title: 'Japan 2026' } as never });
|
||||
render(<RouteJournal />, { initialEntries: ['/journey/12'] });
|
||||
expect(screen.getByText('journal -12 Japan 2026')).toBeInTheDocument();
|
||||
render(<RouteJournal />, { initialEntries: ['/journey/12/studio'] });
|
||||
expect(screen.getAllByText('no journal')).toHaveLength(1);
|
||||
render(<RouteJournal />, { initialEntries: ['/journey'] });
|
||||
expect(screen.getAllByText('no journal')).toHaveLength(2);
|
||||
// Another journey loaded: the key is the route's, the title waits for its own.
|
||||
render(<RouteJournal />, { initialEntries: ['/journey/13'] });
|
||||
expect(screen.getByText(/^journal -13\s*$/)).toBeInTheDocument();
|
||||
useJourneyStore.setState({ current: null });
|
||||
});
|
||||
|
||||
it('FE-AI-HOME-006: a chat switched to from home opens on its own page', () => {
|
||||
expect(chatPagePath({ id: 5, title: 'Kyoto' })).toBe('/trips/5');
|
||||
expect(chatPagePath({ id: -12, title: 'Japan 2026' })).toBe('/journey/12');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMatch } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { assistantChatKey, assistantScopeOf, HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useJourneyStore } from '../../store/journeyStore';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
|
||||
/**
|
||||
@@ -42,3 +44,27 @@ export function useAssistantHomeLauncher() {
|
||||
const open = useAssistantStore((s) => s.open);
|
||||
return { available, open };
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal on screen as a chat: on a journey's own page the journey's
|
||||
* conversation (the one TREK Studio holds too), under its key and with the
|
||||
* journey's title once the page has loaded it; null elsewhere and in the
|
||||
* Studio, whose column holds that chat itself.
|
||||
*/
|
||||
export function useRouteJournalChat(): AssistantChatTrip | null {
|
||||
const match = useMatch('/journey/:id/*');
|
||||
const inStudio = useMatch('/journey/:id/studio/*') !== null;
|
||||
const id = Number(match?.params.id);
|
||||
const journeyId = !inStudio && Number.isInteger(id) && id > 0 ? id : null;
|
||||
const title = useJourneyStore((s) => (s.current && s.current.id === journeyId ? s.current.title : ''));
|
||||
return useMemo(
|
||||
() => (journeyId === null ? null : { id: assistantChatKey({ kind: 'journey', journeyId }), title }),
|
||||
[journeyId, title]
|
||||
);
|
||||
}
|
||||
|
||||
/** The page a chat switched to from the home chat lives on: a journey's for its key, a trip's otherwise. */
|
||||
export function chatPagePath(chat: AssistantChatTrip): string {
|
||||
const scope = assistantScopeOf(chat.id);
|
||||
return scope.kind === 'journey' ? `/journey/${scope.journeyId}` : `/trips/${chat.id}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* FE-AI-STATUS-001 to FE-AI-STATUS-003
|
||||
*
|
||||
* The views of the assistant ask for its status through this hook: not while
|
||||
* both addons that open /api/ai are off, where the server answers 404, and
|
||||
* as soon as one comes on, which the addon list can say after the view
|
||||
* mounted (the first load, an admin's switch). The assistant's own addon
|
||||
* switching asks again while AI parsing keeps /api/ai open.
|
||||
*/
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useAssistantStatus', () => {
|
||||
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
renderHook(() => useAssistantStatus());
|
||||
act(() => addonsOn('mcp'));
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
|
||||
act(() => addonsOn('ai_assistant'));
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The list read again with the assistant still on asks nothing more.
|
||||
act(() => addonsOn('ai_assistant', 'mcp'));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => addonsOn());
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => addonsOn('llm_parsing', 'ai_assistant'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => addonsOn('llm_parsing'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
|
||||
|
||||
/**
|
||||
* Asks for the assistant's status when a view of it mounts, and again when
|
||||
* either addon that opens /api/ai is switched: the addon list can land after
|
||||
* the view, and an admin can switch either addon while it is open. The
|
||||
* assistant's own addon counts on its own, since the status says it runs only
|
||||
* while that one is on, even with AI parsing keeping /api/ai open. The store
|
||||
* asks the server only while one is on (aiRoutesOpen).
|
||||
*/
|
||||
export function useAssistantStatus(): void {
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const routesOpen = useAddonStore(aiRoutesOpen);
|
||||
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [routesOpen, assistantOn, loadStatus]);
|
||||
}
|
||||
@@ -140,7 +140,17 @@ export function ChatBubble({
|
||||
>
|
||||
{reply.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{/* Wrapped and cut after two lines: a quote kept on one line set the bubble's width and pushed it out of the chat. */}
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.8,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{reply.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,13 +25,25 @@ interface CollabLink {
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
// The glyph holds the place until a favicon has really arrived: a host that never
|
||||
// answers, or answers with a blank pixel, left the chip with an empty square.
|
||||
function LinkIcon({ url }: { url: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [state, setState] = useState<'loading' | 'loaded' | 'failed'>('loading')
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
|
||||
const loaded = state === 'loaded'
|
||||
return (
|
||||
<span className="collab-link-chip__icon" aria-hidden="true">
|
||||
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
|
||||
{!loaded && <Link2 size={13} />}
|
||||
{favicon && state !== 'failed' && (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
hidden={!loaded}
|
||||
onLoad={(e) => setState(e.currentTarget.naturalWidth > 2 ? 'loaded' : 'failed')}
|
||||
onError={() => setState('failed')}
|
||||
/>
|
||||
)}
|
||||
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('the Collab pieces', () => {
|
||||
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
|
||||
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||
expect(screen.getByText('Train?')).toBeInTheDocument();
|
||||
// A long quote wraps instead of setting the bubble's width (it pushed the bubble out of the chat).
|
||||
expect(screen.getByText('Train?').getAttribute('style')).toContain('white-space: normal');
|
||||
});
|
||||
|
||||
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
|
||||
|
||||
@@ -38,6 +38,20 @@ function VoterChip({ name, src, offset }: { name: string; src: string | null; of
|
||||
);
|
||||
}
|
||||
|
||||
/** The voters past the third, as a count on the end of the stack; their names on hover. */
|
||||
function MoreVoters({ names }: { names: string[] }) {
|
||||
return (
|
||||
<Tooltip label={names.map((name) => name || '?').join(', ')}>
|
||||
<span
|
||||
className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full border-[1.5px] border-surface-card bg-surface-tertiary px-[3px] font-bold tabular-nums text-content-muted"
|
||||
style={{ ...fs(7), marginInlineStart: -5 }}
|
||||
>
|
||||
+{names.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fact about the poll on its head band, set like the type chip on a booking card. */
|
||||
export function PollChip({
|
||||
icon,
|
||||
@@ -138,6 +152,7 @@ export function PollOptionRow({
|
||||
{voters.slice(0, 3).map((voter, index) => (
|
||||
<VoterChip key={voter.key} name={voter.name} src={voter.src} offset={index > 0} />
|
||||
))}
|
||||
{voters.length > 3 && <MoreVoters names={voters.slice(3).map((voter) => voter.name)} />}
|
||||
</span>
|
||||
)}
|
||||
{pending && <span className="sr-only">{pending.label}</span>}
|
||||
|
||||
@@ -191,6 +191,18 @@ describe('CollabPolls', () => {
|
||||
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-025: past three voters the stack ends in a count of the rest', async () => {
|
||||
const voters = ['anna', 'ben', 'cleo', 'dana', 'emil'].map((username, i) => ({ user_id: 10 + i, username, avatar_url: null }));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ is_closed: true, options: [{ id: 1, text: 'Paris', label: 'Paris', voters }, { id: 2, text: 'Rome', label: 'Rome', voters: [] }] })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('+2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('D')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { buildTrip } from '../../../tests/helpers/factories'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -171,6 +174,20 @@ describe('WhatsNextWidget', () => {
|
||||
expect(screen.getByText('TBD')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-018: a hotel without a time of its own shows the check-in of the stay it begins', async () => {
|
||||
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({
|
||||
accommodations: [{ id: 7, trip_id: 1, place_id: 40, start_day_id: 1, end_day_id: 2, check_in: '15:00', check_out: '11:00' }],
|
||||
})))
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: { '1': [makeAssignment(40, { name: 'Hotel Kea', place_time: null })] },
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(await screen.findByText('15:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('TBD')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,6 +8,8 @@ import { fs } from '../shared/DialogShell'
|
||||
import CollabPanelHead from './CollabPanelHead'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { useTripStays } from '../Planner/useTripStays'
|
||||
import { stayStartingOn } from '../../utils/dayMerge'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
@@ -48,6 +50,10 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
// A hotel on the day its stay begins is timed by the check-in when the stop has no time of its own.
|
||||
const tripId = useTripStore(s => s.trip?.id ?? 0)
|
||||
const { tripAccommodations, loadAccommodations } = useTripStays(tripId)
|
||||
useEffect(() => { loadAccommodations() }, [loadAccommodations])
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date()
|
||||
@@ -60,14 +66,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
for (const a of dayAssignments) {
|
||||
if (!a.place) continue
|
||||
const time = a.place.place_time || stayStartingOn(tripAccommodations, a.place.id, day.id)?.check_in || null
|
||||
// Include: today (not started yet, or still running) + all future days
|
||||
const isFutureDay = day.date > nowDate
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
|
||||
const isTodayFuture = day.date === nowDate && (!time || time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
|
||||
if (isFutureDay || isTodayFuture) {
|
||||
items.push({
|
||||
id: a.id,
|
||||
name: a.place.name,
|
||||
time: a.place.place_time,
|
||||
time,
|
||||
endTime: a.place.end_time,
|
||||
date: day.date,
|
||||
dayTitle: day.title,
|
||||
@@ -88,7 +95,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
})
|
||||
|
||||
return items.slice(0, 8)
|
||||
}, [days, assignments, tripMembers])
|
||||
}, [days, assignments, tripMembers, tripAccommodations])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* The collection map frames every place of the list in the box it is given (FE-COMP-COLMAP-*).
|
||||
*
|
||||
* Real Leaflet on purpose, as in MapView.leaflet.test.tsx. The places went missing between
|
||||
* two sizes: the renderer opened on a camera framed for the browser window, while this map
|
||||
* is a box a fraction of that, and a mocked map has neither. The window here is a desktop
|
||||
* of 1600 x 1000 and the map the 567 x 724 split it gets beside the list, where the demo's
|
||||
* cafes opened on northern France with one pin in sight.
|
||||
*/
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import L from 'leaflet';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import CollectionMap from './CollectionMap';
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: () => ({
|
||||
position: null,
|
||||
mode: 'off',
|
||||
error: null,
|
||||
errorCode: null,
|
||||
cycleMode: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({ FerrataMapLayerLeaflet: () => null }));
|
||||
|
||||
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
|
||||
vi.mock('../../hooks/useTileUrl', () => ({ useTileUrl: () => 'https://tiles.example.test/{z}/{x}/{y}.png' }));
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// Every map this file builds, newest last.
|
||||
const maps: L.Map[] = [];
|
||||
L.Map.addInitHook(function (this: L.Map) {
|
||||
maps.push(this);
|
||||
});
|
||||
|
||||
/** Half a selected pin (44 px): a point this far inside an edge keeps its whole pin on the map. */
|
||||
const PIN = 22;
|
||||
/** The desktop panel's control bar ends 52 px below the map's top edge. */
|
||||
const BAR = 52;
|
||||
/** The satellite and locate buttons fill the bottom 54 px of the map's corners. */
|
||||
const CONTROLS = 54;
|
||||
|
||||
// Leaflet sizes the map from its container, which jsdom lays out at 0 x 0.
|
||||
const box = { width: 567, height: 724 };
|
||||
const sized = ['clientWidth', 'clientHeight'] as const;
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const startWindow = { width: window.innerWidth, height: window.innerHeight };
|
||||
|
||||
function setWindow(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
for (const key of sized) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
|
||||
Object.defineProperty(HTMLElement.prototype, key, {
|
||||
configurable: true,
|
||||
get: () => (key === 'clientWidth' ? box.width : box.height),
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(() => {
|
||||
for (const key of sized) {
|
||||
const original = originals.get(key);
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// The desktop split beside the list, unless a test says otherwise.
|
||||
setWindow(1600, 1000);
|
||||
Object.assign(box, { width: 567, height: 724 });
|
||||
});
|
||||
afterEach(() => {
|
||||
setWindow(startWindow.width, startWindow.height);
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
function place(id: number, name: string, lat: number, lng: number): CollectionPlace {
|
||||
return { id, collection_id: 1, name, lat, lng, status: 'idea' };
|
||||
}
|
||||
|
||||
// The demo's cafes: Vienna twice, Trieste, Rome, Paris, Lisbon, Porto and Copenhagen.
|
||||
const CAFES = [
|
||||
place(1, 'Cafe Central', 48.2105, 16.366),
|
||||
place(2, 'Cafe Sperl', 48.1999, 16.3634),
|
||||
place(3, 'Caffe San Marco', 45.6513, 13.777),
|
||||
place(4, "Sant'Eustachio il Caffe", 41.8984, 12.4753),
|
||||
place(5, 'Cafe de Flore', 48.8542, 2.3331),
|
||||
place(6, 'A Brasileira', 38.7102, -9.1417),
|
||||
place(7, 'Cafe Majestic', 41.1472, -8.6064),
|
||||
place(8, 'The Coffee Collective', 55.696, 12.5509),
|
||||
];
|
||||
|
||||
// The demo's viewpoints, from the Norwegian fjords to the Cinque Terre.
|
||||
const VIEWPOINTS = [
|
||||
place(11, 'Seceda', 46.6046, 11.722),
|
||||
place(12, 'Preikestolen', 58.9863, 6.1904),
|
||||
place(13, 'Trolltunga', 60.1241, 6.74),
|
||||
place(14, 'Rigi Kulm', 47.056, 8.485),
|
||||
place(15, 'Zugspitze', 47.4211, 10.9852),
|
||||
place(16, 'Eibsee loop', 47.4575, 10.9918),
|
||||
place(17, 'Hallstatt overlook', 47.5622, 13.6493),
|
||||
place(18, 'Monterosso al Mare', 44.1456, 9.654),
|
||||
];
|
||||
|
||||
// Temples and shrines across Japan: eight as well, and far outside the cafes' frame.
|
||||
const TEMPLES = [
|
||||
place(31, 'Fushimi Inari', 34.9671, 135.7727),
|
||||
place(32, 'Kiyomizu-dera', 34.9949, 135.785),
|
||||
place(33, 'Senso-ji', 35.7148, 139.7967),
|
||||
place(34, 'Chureito Pagoda', 35.5014, 138.8016),
|
||||
place(35, 'Itsukushima', 34.2959, 132.3199),
|
||||
place(36, 'Himeji Castle', 34.8394, 134.6939),
|
||||
place(37, 'Shirakawa-go', 36.2578, 136.9066),
|
||||
place(38, 'Todai-ji', 34.689, 135.8398),
|
||||
];
|
||||
|
||||
// "All saved" across the globe, from Patagonia to Taiwan.
|
||||
const WORLD = [
|
||||
place(21, 'Torres del Paine', -50.942, -73.4068),
|
||||
place(22, 'Pico do Arieiro', 32.7356, -16.9281),
|
||||
place(23, 'Landmannalaugar', 63.9926, -19.0607),
|
||||
place(24, 'Taroko Gorge', 24.1594, 121.6211),
|
||||
place(25, 'Lycian Way', 36.5497, 29.1153),
|
||||
...VIEWPOINTS.slice(0, 3),
|
||||
];
|
||||
|
||||
function mapOf(places: CollectionPlace[], underBar: boolean) {
|
||||
return (
|
||||
<CollectionMap places={places} selectedPlaceId={null} onOpenPlace={vi.fn()} dark={false} underBar={underBar} />
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMap(places: CollectionPlace[], underBar = true) {
|
||||
const view = render(mapOf(places, underBar));
|
||||
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).not.toBeNull());
|
||||
return { view, map: maps[maps.length - 1] };
|
||||
}
|
||||
|
||||
/** Where each place sits on the map, in pixels from its top left corner. */
|
||||
function pointsOf(map: L.Map, places: CollectionPlace[]): L.Point[] {
|
||||
return places.map((p) => map.latLngToContainerPoint([p.lat ?? 0, p.lng ?? 0]));
|
||||
}
|
||||
|
||||
/** Every pin whole on the map, below the bar when there is one, and above the bottom controls. */
|
||||
function expectEveryPinClear(map: L.Map, places: CollectionPlace[], top: number) {
|
||||
for (const { x, y } of pointsOf(map, places)) {
|
||||
expect(x).toBeGreaterThanOrEqual(PIN);
|
||||
expect(x).toBeLessThanOrEqual(box.width - PIN);
|
||||
expect(y).toBeGreaterThanOrEqual(top + PIN);
|
||||
expect(y).toBeLessThanOrEqual(box.height - CONTROLS - PIN);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an element around the map carries the class. */
|
||||
function wrappedIn(map: L.Map, className: string): boolean {
|
||||
for (let el = map.getContainer().parentElement; el; el = el.parentElement) {
|
||||
if (el.classList.contains(className)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The places fill a good part of the free box along one axis: framed, not shown from orbit. */
|
||||
function expectFramedClosely(map: L.Map, places: CollectionPlace[], top: number) {
|
||||
const points = pointsOf(map, places);
|
||||
const spanX = Math.max(...points.map((p) => p.x)) - Math.min(...points.map((p) => p.x));
|
||||
const spanY = Math.max(...points.map((p) => p.y)) - Math.min(...points.map((p) => p.y));
|
||||
const freeX = box.width - 2 * PIN;
|
||||
const freeY = box.height - top - CONTROLS - 2 * PIN;
|
||||
expect(Math.max(spanX / freeX, spanY / freeY)).toBeGreaterThan(0.4);
|
||||
}
|
||||
|
||||
describe('CollectionMap framing', () => {
|
||||
it('FE-COMP-COLMAP-001: the desktop split opens with every cafe on screen, clear of the edges and the bar', async () => {
|
||||
const { map } = await renderMap(CAFES);
|
||||
|
||||
expectEveryPinClear(map, CAFES, BAR);
|
||||
expectFramedClosely(map, CAFES, BAR);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-002: another list of the same length frames again', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
|
||||
// Both lists hold eight places, so a fit keyed on the count never noticed the switch.
|
||||
// The temples lie far east of the cafes' frame, so their pins come clear only by a new fit.
|
||||
expect(pointsOf(map, TEMPLES).every((p) => p.x > box.width)).toBe(true);
|
||||
view.rerender(mapOf(TEMPLES, true));
|
||||
|
||||
await waitFor(() => expectEveryPinClear(map, TEMPLES, BAR));
|
||||
expectFramedClosely(map, TEMPLES, BAR);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-003: a map the user moved stays put through a reload, a new order or an edited place', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
act(() => {
|
||||
map.setView([50, 10], 7, { animate: false });
|
||||
});
|
||||
|
||||
const reloaded = [...CAFES].reverse().map((p) => ({ ...p, status: 'visited' as const }));
|
||||
view.rerender(mapOf(reloaded, true));
|
||||
|
||||
expect(map.getZoom()).toBe(7);
|
||||
expect(map.getCenter().lat).toBeCloseTo(50, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(10, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-004: a filter that hides every place leaves the map where it was', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
const zoom = map.getZoom();
|
||||
const center = map.getCenter();
|
||||
|
||||
view.rerender(mapOf([], true));
|
||||
|
||||
expect(map.getZoom()).toBe(zoom);
|
||||
expect(map.getCenter().lat).toBeCloseTo(center.lat, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(center.lng, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-005: a list of one opens on its town, not on its street', async () => {
|
||||
const { map } = await renderMap([CAFES[0]]);
|
||||
|
||||
expectEveryPinClear(map, [CAFES[0]], BAR);
|
||||
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
|
||||
expect(map.getZoom()).toBeLessThanOrEqual(13);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-006: "All saved" across the globe keeps Patagonia and Taiwan on screen', async () => {
|
||||
const { map } = await renderMap(WORLD);
|
||||
|
||||
expectEveryPinClear(map, WORLD, BAR);
|
||||
});
|
||||
|
||||
it("FE-COMP-COLMAP-007: the phone's card frames every cafe clear of its edges and its bottom controls", async () => {
|
||||
setWindow(390, 844);
|
||||
Object.assign(box, { width: 356, height: 414 });
|
||||
|
||||
const { map } = await renderMap(CAFES, false);
|
||||
|
||||
expectEveryPinClear(map, CAFES, 0);
|
||||
expectFramedClosely(map, CAFES, 0);
|
||||
// The locate and satellite buttons stand on --bottom-nav-h, which lifts them over the tab
|
||||
// bar under a full-screen map. The card ends above the tab bar, so its wrapper zeroes it
|
||||
// and the buttons sit in the bottom 54 px the frame keeps free.
|
||||
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
|
||||
interface CollectionMapProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -12,20 +13,75 @@ interface CollectionMapProps {
|
||||
/** Clicking the map background clears the selection. */
|
||||
onDeselect?: () => void
|
||||
dark: boolean
|
||||
/** A control bar floats over the top of the map (the desktop panel's), so the frame starts below it. */
|
||||
underBar?: boolean
|
||||
}
|
||||
|
||||
/** How far a pin reaches from its point: half a selected pin (44 px) and the rating badge on its corner. */
|
||||
const PIN_REACH = 26
|
||||
/** What every edge keeps clear: a pin's reach and a little air, so no pin touches the frame. */
|
||||
const EDGE = PIN_REACH + 14
|
||||
/** The desktop panel's control bar: 12 px off the top edge and at most 40 px tall. */
|
||||
const TOP_BAR = 52
|
||||
/** The round controls in the bottom corners (satellite, locate): 12 px off the edge and 42 px tall. */
|
||||
const BOTTOM_CONTROLS = 54
|
||||
/**
|
||||
* Half the side of the square framed around a lone place, in degrees: about 11 km
|
||||
* from north to south, so a list of one opens on its town rather than on the street
|
||||
* the closest fit would zoom to.
|
||||
*/
|
||||
const LONE_PLACE_SPAN = 0.05
|
||||
|
||||
/** What the frame keeps clear of pins, in map pixels per edge. */
|
||||
function framePadding(underBar: boolean): ViewportPadding {
|
||||
const top = (underBar ? TOP_BAR : 0) + EDGE
|
||||
return { top, right: EDGE, bottom: BOTTOM_CONTROLS + PIN_REACH, left: EDGE } // rtl-lint-disable: pixels of the map, not sides of the text
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
|
||||
* The coordinates the map frames. A lone place, or several on one spot, has no extent
|
||||
* to fit, so a small square around it stands in.
|
||||
*/
|
||||
function framePoints(places: CollectionPlace[]): [number, number][] {
|
||||
const points = places.flatMap(p => (
|
||||
typeof p.lat === 'number' && typeof p.lng === 'number' ? [[p.lat, p.lng] as [number, number]] : []
|
||||
))
|
||||
if (points.length === 0) return points
|
||||
const [lat, lng] = points[0]
|
||||
if (points.some(([a, b]) => a !== lat || b !== lng)) return points
|
||||
return [
|
||||
[Math.max(-90, lat - LONE_PLACE_SPAN), lng - LONE_PLACE_SPAN],
|
||||
[Math.min(90, lat + LONE_PLACE_SPAN), lng + LONE_PLACE_SPAN],
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view: reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
|
||||
* clustering). One of the three list views; clicking a marker selects the place.
|
||||
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
|
||||
* just fills it.
|
||||
*
|
||||
* The map frames every place itself, through the renderers' focus props. Their
|
||||
* opening camera is framed for the browser window, and this map is a box a fraction
|
||||
* of that size (the desktop split, the phone's card), so it opened zoomed in on the
|
||||
* middle of the list with most places outside; and the count it used to key the fit
|
||||
* on never changed between two lists of eight. The frame is fitted in the map's own
|
||||
* box instead, clear of the edges and of the controls floating over it, and lands
|
||||
* while the map is built rather than flying in.
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
|
||||
const pts = useMemo(() => mappablePlaces(places), [places])
|
||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
||||
const focusPoints = useMemo(() => framePoints(pts), [pts])
|
||||
// The places as a set: another list, a filter or a new place frames again, while a
|
||||
// reload, another sort order or an edited note leaves a map the user moved alone.
|
||||
const focusKey = useMemo(() => pts.map(p => p.id).sort((a, b) => a - b).join(','), [pts])
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
// The map's own controls stand on --bottom-nav-h to clear the phone's tab bar on a
|
||||
// full-screen map. This one ends above the tab bar, so they belong on its bottom edge,
|
||||
// where the frame's bottom padding keeps the pins out from under them.
|
||||
<div className="[--bottom-nav-h:0px]" style={{ width: '100%', height: '100%' }}>
|
||||
<MapViewAuto
|
||||
places={pts}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
@@ -35,10 +91,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
|
||||
// question a small picture cannot answer.
|
||||
onMarkerClick={onOpenPlace}
|
||||
onMapClick={onDeselect ? () => onDeselect() : undefined}
|
||||
// No center/zoom: the map frames itself on the collection's places at mount, and
|
||||
// falls back to the world view for a collection with none.
|
||||
tileUrl={tileUrl}
|
||||
fitKey={pts.length}
|
||||
focusPoints={focusPoints}
|
||||
focusKey={focusKey}
|
||||
fitPadding={framePadding(underBar)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The collections page's map panel tells the map whether its bar floats over it (FE-COMP-COLMAPPANEL-*).
|
||||
*
|
||||
* The frame itself is CollectionMap.test.tsx's. The page tests stand the panel in with a double,
|
||||
* so this is where the bar's height reaches the frame: without it the top pins land under the bar.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import CollectionMapPanel from './CollectionMapPanel';
|
||||
|
||||
const handedToMap = vi.hoisted(() => ({ underBar: undefined as boolean | undefined }));
|
||||
vi.mock('./CollectionMap', () => ({
|
||||
default: (props: { underBar?: boolean }) => {
|
||||
handedToMap.underBar = props.underBar;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function panel(overlay: boolean) {
|
||||
return (
|
||||
<CollectionMapPanel
|
||||
places={[]}
|
||||
selectedPlaceId={null}
|
||||
onSelect={vi.fn()}
|
||||
onDeselect={vi.fn()}
|
||||
dark={false}
|
||||
overlay={overlay}
|
||||
view="list"
|
||||
onToggleView={vi.fn()}
|
||||
search=""
|
||||
onSearch={vi.fn()}
|
||||
t={(key) => key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CollectionMapPanel', () => {
|
||||
it('FE-COMP-COLMAPPANEL-001: the desktop split floats its bar over the map, which frames its places below it', () => {
|
||||
const { container } = render(panel(true));
|
||||
|
||||
expect(container.querySelector('.col-map-topbar')).not.toBeNull();
|
||||
expect(handedToMap.underBar).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAPPANEL-002: a map without the bar frames its places from its own top edge', () => {
|
||||
const { container } = render(panel(false));
|
||||
|
||||
expect(container.querySelector('.col-map-topbar')).toBeNull();
|
||||
expect(handedToMap.underBar).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export default function CollectionMapPanel({
|
||||
onOpenPlace={onSelect}
|
||||
onDeselect={onDeselect}
|
||||
dark={dark}
|
||||
underBar={overlay}
|
||||
/>
|
||||
{overlay && (
|
||||
<div className="col-map-topbar">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import {
|
||||
PHOTO_CREDITS,
|
||||
PUBLIC_DOMAIN,
|
||||
commonsPage,
|
||||
creditTitle,
|
||||
licenseDeed,
|
||||
type PhotoCredit,
|
||||
} from './demoCreditsModel';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
const LINK = 'underline decoration-edge underline-offset-2 hover:text-content';
|
||||
|
||||
/** One photo: its title linking to its page on Commons, who took it, and its license linking to the deed. */
|
||||
function CreditRow({ credit, t }: { credit: PhotoCredit; t: Translate }) {
|
||||
const deed = licenseDeed(credit.license);
|
||||
const license = credit.license === PUBLIC_DOMAIN ? t('demo.credits.publicDomain') : credit.license;
|
||||
return (
|
||||
<li className="flex flex-col gap-0.5 py-2.5">
|
||||
<a
|
||||
href={commonsPage(credit.file)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${LINK} self-start break-words font-medium text-content`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{creditTitle(credit.file)}
|
||||
</a>
|
||||
<span className="flex flex-wrap gap-x-3 gap-y-0.5 text-content-muted" style={fs(12, 'body')}>
|
||||
<span>{t('demo.credits.by', { author: credit.author })}</span>
|
||||
{deed ? (
|
||||
<a href={deed} target="_blank" rel="noopener noreferrer" className={LINK}>
|
||||
{license}
|
||||
</a>
|
||||
) : (
|
||||
<span>{license}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credits for the demo's photos, the welcome dialog's second view. Its own
|
||||
* chunk, loaded when a visitor opens it: only a demo instance shows the
|
||||
* welcome, and few visitors open its credits.
|
||||
*/
|
||||
export default function DemoCredits() {
|
||||
const { t } = useTranslation();
|
||||
const intro = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// The dialog's body keeps the welcome's scroll position when the credits
|
||||
// take its place; they start at their top.
|
||||
useEffect(() => {
|
||||
intro.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p ref={intro} className="m-0 scroll-mt-5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{t('demo.credits.intro')}
|
||||
</p>
|
||||
<ul
|
||||
aria-label={t('demo.credits.title')}
|
||||
className="m-0 list-none divide-y divide-edge-faint rounded-[16px] border border-edge-faint bg-surface-card px-3.5"
|
||||
>
|
||||
{PHOTO_CREDITS.map((credit) => (
|
||||
<CreditRow key={credit.file} credit={credit} t={t} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoLock from './DemoLock';
|
||||
|
||||
describe('DemoLock', () => {
|
||||
it('FE-COMP-DEMOLOCK-001: unlocked, the section is the section and nothing more', () => {
|
||||
const { container } = render(
|
||||
<DemoLock locked={false} reason="Not here.">
|
||||
<button type="button">Create key</button>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create key' })).toBeEnabled();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-demo-lock]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOLOCK-002: locked, the section stays visible but out of reach, under the badge and the reason', () => {
|
||||
const { container } = render(
|
||||
<DemoLock locked reason="API keys cannot be created in the demo.">
|
||||
<button type="button">Create key</button>
|
||||
</DemoLock>
|
||||
);
|
||||
const note = screen.getByRole('note');
|
||||
expect(note).toHaveTextContent('Demo mode');
|
||||
expect(note).toHaveTextContent('API keys cannot be created in the demo.');
|
||||
expect(screen.queryByRole('button', { name: 'Create key' })).not.toBeInTheDocument();
|
||||
const veiled = container.querySelector('[data-demo-lock] > [inert]');
|
||||
expect(veiled).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(veiled).toContainElement(screen.getByRole('button', { hidden: true, name: 'Create key' }));
|
||||
expect(note.getAttribute('style')).toContain('repeating-linear-gradient');
|
||||
expect(note.getAttribute('style')).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOLOCK-003: a single row puts the note in its middle, a section at its top', () => {
|
||||
const { rerender } = render(
|
||||
<DemoLock locked reason="r" size="row">
|
||||
<span>row</span>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('note').className).toContain('items-center');
|
||||
rerender(
|
||||
<DemoLock locked reason="r">
|
||||
<span>section</span>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('note').className).toContain('items-start');
|
||||
});
|
||||
});
|
||||
@@ -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/'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isDemoAccountEmail } from '@trek/shared';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
/**
|
||||
* Whether the account signed in here is the shared demo account of a demo
|
||||
* instance. Every visitor signs in as that one account, so its Account tab,
|
||||
* API keys, MCP configuration, own AI connection and language sit under a
|
||||
* DemoLock; the server refuses the same writes (`demo-write.ts`). The admin
|
||||
* of a demo instance, and every account of a real one, see the sections as
|
||||
* they are.
|
||||
*/
|
||||
export function useDemoAccountLock(): boolean {
|
||||
const demoMode = useAuthStore((s) => s.demoMode);
|
||||
const email = useAuthStore((s) => s.user?.email);
|
||||
return demoMode && isDemoAccountEmail(email);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { formatCountdown, secondsUntilReset } from './demoWelcomeModel';
|
||||
|
||||
/**
|
||||
* Whether the photo credits have the dialog: not yet, now, or no longer
|
||||
* (the visitor came back from them, and the focus returns to their link).
|
||||
*/
|
||||
type CreditsView = 'unseen' | 'open' | 'left';
|
||||
|
||||
/**
|
||||
* The welcome dialog's state: whether it is still open, whether it shows the
|
||||
* photo credits in place of the welcome, and the live countdown to the next
|
||||
* hourly reset, ticking once a second in the server's time zone.
|
||||
*/
|
||||
export function useDemoWelcome() {
|
||||
const timeZone = useAuthStore((s) => s.serverTimezone);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [credits, setCredits] = useState<CreditsView>('unseen');
|
||||
const [secondsLeft, setSecondsLeft] = useState(() => secondsUntilReset(new Date(), timeZone));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setInterval(() => setSecondsLeft(secondsUntilReset(new Date(), timeZone)), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [open, timeZone]);
|
||||
|
||||
return {
|
||||
open,
|
||||
close: () => setOpen(false),
|
||||
countdown: formatCountdown(secondsLeft),
|
||||
creditsOpen: credits === 'open',
|
||||
backFromCredits: credits === 'left',
|
||||
openCredits: () => setCredits('open'),
|
||||
closeCredits: () => setCredits('left'),
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import CustomSelect from '../../shared/CustomSelect'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import Tooltip from '../../shared/Tooltip'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
|
||||
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
|
||||
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
|
||||
|
||||
@@ -6,7 +6,7 @@ import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import TrekIcon from '../../shared/TrekIcon'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { toggledDirection, type SyncDirection } from './docSyncModel'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { AlertCircle, ArrowRight, Check, FolderSync, Loader2, Plus } from 'lucide-react'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import { StateBadge } from './DocSyncBits'
|
||||
import { slugFor } from './docSyncModel'
|
||||
import { useConflicts } from './useConflicts'
|
||||
|
||||
@@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import type { JourneyMapGLHandle } from './JourneyMapGL'
|
||||
|
||||
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
|
||||
import { useGlRenderer } from '../Map/glSupport'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import type { MapPhoto } from './journeyPhotoLayer'
|
||||
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||
@@ -56,10 +57,11 @@ function JourneyMapAuto({ ref, ...props }: Props) {
|
||||
const leafletRef = useRef<JourneyMapHandle>(null)
|
||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
|
||||
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't supplied a
|
||||
// token yet (MapLibre/OpenFreeMap is tokenless), or when the browser has no WebGL2.
|
||||
const glRenderer = useGlRenderer(provider, token)
|
||||
const useGL = glRenderer !== null
|
||||
const glProvider = glRenderer ?? 'mapbox-gl'
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||
|
||||
@@ -107,7 +107,8 @@ const glModule = vi.hoisted(() => () => ({
|
||||
}))
|
||||
|
||||
vi.mock('mapbox-gl', glModule)
|
||||
vi.mock('maplibre-gl', glModule)
|
||||
// maplibre-gl 6 has no default export: the same fakes, as named exports.
|
||||
vi.mock('maplibre-gl', () => glModule().default)
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
@@ -121,7 +122,7 @@ vi.mock('../Map/mapboxSetup', () => ({
|
||||
|
||||
import React from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
@@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import { buildGlMap } from '../Map/glSupport'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
@@ -360,11 +361,13 @@ function JourneyMapGL(
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
// Null when the GPU refused WebGL2: JourneyMapAuto has switched to Leaflet by then.
|
||||
const map = buildGlMap(() => new gl.Map(mapOptions as any))
|
||||
if (!map) return
|
||||
mapRef.current = map
|
||||
marksFramedRef.current = false
|
||||
entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])]
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoBanner from './DemoBanner';
|
||||
|
||||
describe('DemoBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-001
|
||||
it('renders without crashing', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-002
|
||||
it('overlay is visible on initial render with dismiss button', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-003
|
||||
it('shows English welcome title by default', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-004
|
||||
it('clicking "Got it" dismisses the banner', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
const button = screen.getByText('Got it');
|
||||
await user.click(button);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-005
|
||||
it('clicking the overlay backdrop dismisses the banner', () => {
|
||||
const { container } = render(<DemoBanner />);
|
||||
// The outermost fixed div is the overlay backdrop
|
||||
const overlay = container.firstChild as HTMLElement;
|
||||
fireEvent.click(overlay);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-006
|
||||
it('clicking the inner card does NOT dismiss', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
// The inner card is the direct parent of the "Got it" button's container
|
||||
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
|
||||
await user.click(card);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-007
|
||||
it('shows reset timer', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-008
|
||||
it('shows upload-disabled notice', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-009
|
||||
it('shows "What is TREK?" section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-010
|
||||
it('shows addon cards', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-011
|
||||
it('shows full version features section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-012
|
||||
it('self-host link points to GitHub', () => {
|
||||
render(<DemoBanner />);
|
||||
const link = screen.getByText('self-host it').closest('a')!;
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
// Timer update test
|
||||
it('updates countdown timer after interval tick', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
|
||||
|
||||
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
|
||||
await act(async () => {
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
|
||||
vi.advanceTimersByTime(10000);
|
||||
});
|
||||
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,613 +0,0 @@
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Database,
|
||||
FileText,
|
||||
Github,
|
||||
Globe,
|
||||
Key,
|
||||
ListChecks,
|
||||
Map,
|
||||
Puzzle,
|
||||
Shield,
|
||||
Upload,
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface DemoTexts {
|
||||
titleBefore: string;
|
||||
titleAfter: string;
|
||||
title: string;
|
||||
description: string;
|
||||
resetIn: string;
|
||||
minutes: string;
|
||||
uploadNote: string;
|
||||
fullVersionTitle: string;
|
||||
features: string[];
|
||||
addonsTitle: string;
|
||||
addons: [string, string][];
|
||||
whatIs: string;
|
||||
whatIsDesc: string;
|
||||
selfHost: string;
|
||||
selfHostLink: string;
|
||||
close: string;
|
||||
}
|
||||
|
||||
const texts: Record<string, DemoTexts> = {
|
||||
de: {
|
||||
titleBefore: 'Willkommen bei ',
|
||||
titleAfter: '',
|
||||
title: 'Willkommen zur TREK Demo',
|
||||
description:
|
||||
'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||
resetIn: 'Naechster Reset in',
|
||||
minutes: 'Minuten',
|
||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||||
features: [
|
||||
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||||
'API-Schluessel (Google Maps, Wetter)',
|
||||
'Benutzer- & Rechteverwaltung',
|
||||
'Automatische Backups',
|
||||
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||||
'OIDC / SSO Single Sign-On',
|
||||
],
|
||||
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||||
addons: [
|
||||
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||||
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||||
['Packliste', 'Checklisten pro Reise'],
|
||||
['Budget', 'Kostenplanung mit Splitting'],
|
||||
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||
],
|
||||
whatIs: 'Was ist TREK?',
|
||||
whatIsDesc:
|
||||
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||
selfHost: 'Open Source — ',
|
||||
selfHostLink: 'selbst hosten',
|
||||
close: 'Verstanden',
|
||||
},
|
||||
en: {
|
||||
titleBefore: 'Welcome to ',
|
||||
titleAfter: '',
|
||||
title: 'Welcome to the TREK Demo',
|
||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||
resetIn: 'Next reset in',
|
||||
minutes: 'minutes',
|
||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||
fullVersionTitle: 'Additionally in the full version:',
|
||||
features: [
|
||||
'File uploads (photos, documents, covers)',
|
||||
'API key management (Google Maps, Weather)',
|
||||
'User & permission management',
|
||||
'Automatic backups',
|
||||
'Addon management (enable/disable)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||||
addons: [
|
||||
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||||
['Atlas', 'World map with visited countries & travel stats'],
|
||||
['Packing', 'Checklists per trip'],
|
||||
['Budget', 'Expense tracking with splitting'],
|
||||
['Documents', 'Attach files to trips'],
|
||||
['Widgets', 'Currency converter & timezones'],
|
||||
],
|
||||
whatIs: 'What is TREK?',
|
||||
whatIsDesc:
|
||||
'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||
selfHost: 'Open source — ',
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
},
|
||||
et: {
|
||||
titleBefore: 'Tere tulemast ',
|
||||
titleAfter: 'i',
|
||||
title: 'Tere tulemast TREKi demosse',
|
||||
description: 'Saad reise vaadata, muuta ja luua. Kõik muudatused lähtestatakse automaatselt iga tunni järel.',
|
||||
resetIn: 'Järgmise lähtestamiseni',
|
||||
minutes: 'minutit',
|
||||
uploadNote: 'Failide (fotode, dokumentide ja kaanepiltide) üleslaadimine on demorežiimis keelatud.',
|
||||
fullVersionTitle: 'Täisversioonis lisaks:',
|
||||
features: [
|
||||
'Failide üleslaadimine (fotod, dokumendid, kaanepildid)',
|
||||
'API-võtmete haldus (Google Maps, ilm)',
|
||||
'Kasutajate ja õiguste haldus',
|
||||
'Automaatsed varukoopiad',
|
||||
'Lisamoodulite haldus (lubamine/keelamine)',
|
||||
'OIDC / SSO ühekordne sisselogimine',
|
||||
],
|
||||
addonsTitle: 'Lisamoodulid (täisversioonis saab välja lülitada)',
|
||||
addons: [
|
||||
['Vacay', 'Puhkuseplaneerija kalendri, pühade ja kasutajate plaanide ühendamisega'],
|
||||
['Atlas', 'Maailmakaart külastatud riikide ja reisistatistikaga'],
|
||||
['Pakkimine', 'Iga reisi kontrollnimekirjad'],
|
||||
['Eelarve', 'Kulude jälgimine ja jagamine'],
|
||||
['Dokumendid', 'Lisa reisidele faile'],
|
||||
['Vidinad', 'Valuutakalkulaator ja ajavööndid'],
|
||||
],
|
||||
whatIs: 'Mis on TREK?',
|
||||
whatIsDesc:
|
||||
'Ise majutatav reisiplaneerija reaalajas koostöö, interaktiivsete kaartide, OIDC sisselogimise ja tumeda režiimiga.',
|
||||
selfHost: 'Avatud lähtekood — ',
|
||||
selfHostLink: 'majuta ise',
|
||||
close: 'Selge',
|
||||
},
|
||||
es: {
|
||||
titleBefore: 'Bienvenido a ',
|
||||
titleAfter: '',
|
||||
title: 'Bienvenido a la demo de TREK',
|
||||
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
|
||||
resetIn: 'Próximo reinicio en',
|
||||
minutes: 'minutos',
|
||||
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
|
||||
fullVersionTitle: 'Además, en la versión completa:',
|
||||
features: [
|
||||
'Subida de archivos (fotos, documentos, portadas)',
|
||||
'Gestión de claves API (Google Maps, tiempo)',
|
||||
'Gestión de usuarios y permisos',
|
||||
'Copias de seguridad automáticas',
|
||||
'Gestión de addons (activar/desactivar)',
|
||||
'Inicio de sesión único OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
|
||||
addons: [
|
||||
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
|
||||
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
|
||||
['Equipaje', 'Listas de comprobación para cada viaje'],
|
||||
['Presupuesto', 'Control de gastos con reparto'],
|
||||
['Documentos', 'Adjunta archivos a los viajes'],
|
||||
['Widgets', 'Conversor de divisas y zonas horarias'],
|
||||
],
|
||||
whatIs: '¿Qué es TREK?',
|
||||
whatIsDesc:
|
||||
'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||||
selfHost: 'Código abierto — ',
|
||||
selfHostLink: 'alójalo tú mismo',
|
||||
close: 'Entendido',
|
||||
},
|
||||
zh: {
|
||||
titleBefore: '欢迎来到 ',
|
||||
titleAfter: '',
|
||||
title: '欢迎来到 TREK 演示版',
|
||||
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
|
||||
resetIn: '下次重置将在',
|
||||
minutes: '分钟后',
|
||||
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
|
||||
fullVersionTitle: '完整版本还包括:',
|
||||
features: [
|
||||
'文件上传(照片、文档、封面)',
|
||||
'API 密钥管理(Google Maps、天气)',
|
||||
'用户和权限管理',
|
||||
'自动备份',
|
||||
'附加组件管理(启用/禁用)',
|
||||
'OIDC / SSO 单点登录',
|
||||
],
|
||||
addonsTitle: '模块化附加组件(完整版本可禁用)',
|
||||
addons: [
|
||||
['Vacay', '带日历、节假日和用户融合的假期规划器'],
|
||||
['Atlas', '带已访问国家和旅行统计的世界地图'],
|
||||
['Packing', '按旅行管理清单'],
|
||||
['Budget', '支持分摊的费用追踪'],
|
||||
['Documents', '将文件附加到旅行'],
|
||||
['Widgets', '货币换算和时区工具'],
|
||||
],
|
||||
whatIs: '什么是 TREK?',
|
||||
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
|
||||
selfHost: '开源项目 - ',
|
||||
selfHostLink: '自行部署',
|
||||
close: '知道了',
|
||||
},
|
||||
'zh-TW': {
|
||||
titleBefore: '歡迎來到 ',
|
||||
titleAfter: '',
|
||||
title: '歡迎來到 TREK 展示版',
|
||||
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
|
||||
resetIn: '下次重設將在',
|
||||
minutes: '分鐘後',
|
||||
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
|
||||
fullVersionTitle: '完整版本還包含:',
|
||||
features: [
|
||||
'檔案上傳(照片、文件、封面)',
|
||||
'API 金鑰管理(Google Maps、天氣)',
|
||||
'使用者與權限管理',
|
||||
'自動備份',
|
||||
'附加元件管理(啟用/停用)',
|
||||
'OIDC / SSO 單一登入',
|
||||
],
|
||||
addonsTitle: '模組化附加元件(完整版本可停用)',
|
||||
addons: [
|
||||
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
|
||||
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
|
||||
['Packing', '依行程管理的檢查清單'],
|
||||
['Budget', '支援分攤的費用追蹤'],
|
||||
['Documents', '將檔案附加到行程'],
|
||||
['Widgets', '貨幣換算與時區工具'],
|
||||
],
|
||||
whatIs: 'TREK 是什麼?',
|
||||
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
|
||||
selfHost: '開源專案 - ',
|
||||
selfHostLink: '自行架設',
|
||||
close: '知道了',
|
||||
},
|
||||
ar: {
|
||||
titleBefore: 'مرحبًا بك في ',
|
||||
titleAfter: '',
|
||||
title: 'مرحبًا بك في النسخة التجريبية من TREK',
|
||||
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
|
||||
resetIn: 'إعادة الضبط التالية خلال',
|
||||
minutes: 'دقيقة',
|
||||
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
|
||||
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
|
||||
features: [
|
||||
'رفع الملفات (الصور والمستندات وصور الغلاف)',
|
||||
'إدارة مفاتيح API (خرائط Google والطقس)',
|
||||
'إدارة المستخدمين والصلاحيات',
|
||||
'نسخ احتياطية تلقائية',
|
||||
'إدارة الإضافات (تفعيل/تعطيل)',
|
||||
'تسجيل دخول موحد OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
|
||||
addons: [
|
||||
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
|
||||
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
|
||||
['Packing', 'قوائم تجهيز لكل رحلة'],
|
||||
['Budget', 'تتبع المصروفات مع التقسيم'],
|
||||
['Documents', 'إرفاق الملفات بالرحلات'],
|
||||
['Widgets', 'محول عملات ومناطق زمنية'],
|
||||
],
|
||||
whatIs: 'ما هو TREK؟',
|
||||
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
|
||||
selfHost: 'مفتوح المصدر — ',
|
||||
selfHostLink: 'استضفه بنفسك',
|
||||
close: 'فهمت',
|
||||
},
|
||||
id: {
|
||||
titleBefore: 'Selamat datang di ',
|
||||
titleAfter: '',
|
||||
title: 'Selamat datang di Demo TREK',
|
||||
description:
|
||||
'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
|
||||
resetIn: 'Atur ulang berikutnya dalam',
|
||||
minutes: 'menit',
|
||||
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
|
||||
fullVersionTitle: 'Selain itu dalam versi lengkap:',
|
||||
features: [
|
||||
'Unggah file (foto, dokumen, sampul)',
|
||||
'Manajemen kunci API (Google Maps, Cuaca)',
|
||||
'Manajemen pengguna & izin',
|
||||
'Pencadangan otomatis',
|
||||
'Manajemen Addon (aktifkan/nonaktifkan)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
|
||||
addons: [
|
||||
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
|
||||
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
|
||||
['Pengepakan', 'Daftar periksa per perjalanan'],
|
||||
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
|
||||
['Dokumen', 'Lampirkan file ke perjalanan'],
|
||||
['Widget', 'Konverter mata uang & zona waktu'],
|
||||
],
|
||||
whatIs: 'Apa itu TREK?',
|
||||
whatIsDesc:
|
||||
'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
|
||||
selfHost: 'Buka sumber — ',
|
||||
selfHostLink: 'host mandiri',
|
||||
close: 'Mengerti',
|
||||
},
|
||||
};
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield];
|
||||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft];
|
||||
|
||||
export default function DemoBanner(): React.ReactElement | null {
|
||||
const [dismissed, setDismissed] = useState<boolean>(false);
|
||||
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes());
|
||||
const { language } = useTranslation();
|
||||
const t = texts[language] || texts.en;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingInline: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--font-system)',
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t.close}
|
||||
onClick={() => setDismissed(true)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setDismissed(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: 20,
|
||||
padding: '28px 24px 0',
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#111827',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
{t.titleBefore}
|
||||
<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />
|
||||
{t.titleAfter}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.6,
|
||||
margin: '0 0 12px',
|
||||
}}
|
||||
>
|
||||
{t.description}
|
||||
</p>
|
||||
|
||||
{/* Timer + Upload note */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}>
|
||||
{t.resetIn} {minutesLeft} {t.minutes}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#fffbeb',
|
||||
border: '1px solid #fde68a',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>
|
||||
{t.uploadNote}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What is TREK */}
|
||||
<div
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 12,
|
||||
padding: '12px 14px',
|
||||
marginBottom: 16,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Map size={14} style={{ color: '#111827' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#111827',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{t.whatIs}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>
|
||||
{t.whatIsDesc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Addons */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#374151',
|
||||
margin: '0 0 8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Puzzle size={12} />
|
||||
{t.addonsTitle}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.addons.map(([name, desc], i) => {
|
||||
const Icon = addonIcons[i];
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||||
<span
|
||||
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
color: '#94a3b8',
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
paddingInlineStart: 18,
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Full version features */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#374151',
|
||||
margin: '0 0 8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Shield size={12} />
|
||||
{t.fullVersionTitle}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.features.map((text, i) => {
|
||||
const Icon = featureIcons[i];
|
||||
return (
|
||||
<div
|
||||
key={text}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
color: '#4b5563',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 0 20px',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
color: '#9ca3af',
|
||||
}}
|
||||
>
|
||||
<Github size={13} />
|
||||
<span>{t.selfHost}</span>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
|
||||
>
|
||||
{t.selfHostLink}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
style={{
|
||||
background: '#111827',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
padding: '8px 20px',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -534,6 +534,21 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-089: a frame handed over while the map is built lands at once, a later one flies', () => {
|
||||
// The map opens on a guess framed for the browser window. A map in a smaller box, the
|
||||
// collection's beside its list, hands its own frame over at once, and must open there
|
||||
// rather than show the guess and then fly to the frame.
|
||||
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
|
||||
const { rerender } = render(<MapView places={[]} focusKey="list:17" focusPoints={cafes} />)
|
||||
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: false, maxZoom: 16 })]])
|
||||
mapMock.fitBounds.mockClear()
|
||||
|
||||
// Another list on the same map is a change the user asked for, and it flies as before.
|
||||
const viewpoints: [number, number][] = [[60.12, 6.74], [44.15, 9.65], [47.56, 13.65]]
|
||||
rerender(<MapView places={[]} focusKey="list:20" focusPoints={viewpoints} />)
|
||||
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: true, maxZoom: 16 })]])
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
|
||||
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
|
||||
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user