mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 17:13:01 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 221,
|
||||
"complexity": 220,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -408,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } : {}) }
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
/**
|
||||
@@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
const home = useAssistantHome(setPicked);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
useAssistantStatus();
|
||||
useAssistantHotkey(available, toggle);
|
||||
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* FE-AI-STATUS-001 to FE-AI-STATUS-003
|
||||
*
|
||||
* The views of the assistant ask for its status through this hook: not while
|
||||
* both addons that open /api/ai are off, where the server answers 404, and
|
||||
* as soon as one comes on, which the addon list can say after the view
|
||||
* mounted (the first load, an admin's switch). The assistant's own addon
|
||||
* switching asks again while AI parsing keeps /api/ai open.
|
||||
*/
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useAssistantStatus', () => {
|
||||
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
renderHook(() => useAssistantStatus());
|
||||
act(() => addonsOn('mcp'));
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
|
||||
act(() => addonsOn('ai_assistant'));
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The list read again with the assistant still on asks nothing more.
|
||||
act(() => addonsOn('ai_assistant', 'mcp'));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => addonsOn());
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => addonsOn('llm_parsing', 'ai_assistant'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => addonsOn('llm_parsing'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
|
||||
|
||||
/**
|
||||
* Asks for the assistant's status when a view of it mounts, and again when
|
||||
* either addon that opens /api/ai is switched: the addon list can land after
|
||||
* the view, and an admin can switch either addon while it is open. The
|
||||
* assistant's own addon counts on its own, since the status says it runs only
|
||||
* while that one is on, even with AI parsing keeping /api/ai open. The store
|
||||
* asks the server only while one is on (aiRoutesOpen).
|
||||
*/
|
||||
export function useAssistantStatus(): void {
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const routesOpen = useAddonStore(aiRoutesOpen);
|
||||
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [routesOpen, assistantOn, loadStatus]);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]]]
|
||||
|
||||
@@ -427,35 +427,16 @@ function MapController({ center, zoom }: MapControllerProps) {
|
||||
// fit to that day's destinations immediately, then — once the day's route has
|
||||
// finished computing asynchronously — re-fit once more to include the full route
|
||||
// polyline, so a route that bulges past its stops stays in view (#1128).
|
||||
interface BoundsControllerProps {
|
||||
// The focus props are the contract's, documented there once rather than copied here.
|
||||
interface BoundsControllerProps extends Pick<MapViewProps, 'focusPoints' | 'focusKey' | 'fitPadding'> {
|
||||
places: Place[]
|
||||
routeCoords: [number, number][]
|
||||
fitKey: number
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number | string
|
||||
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
|
||||
follow?: boolean
|
||||
/**
|
||||
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
|
||||
*
|
||||
* `paddingOpts` knows the planner's panels and nothing else, and on a phone it is a flat
|
||||
* margin. A shell that lays its own bars over the map passes what they cover, so the
|
||||
* frame lands in the part still visible. Only the fit on `focusPoints` reads it.
|
||||
* Compared by value: the same numbers in a new object do not refit, while new numbers
|
||||
* refit the points already handed over, because the chrome they must clear has moved.
|
||||
*/
|
||||
fitPadding?: ViewportPadding
|
||||
}
|
||||
|
||||
/** A padding box as Leaflet's fit options spell it, corner pairs rather than edges. */
|
||||
@@ -473,6 +454,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
const fitRan = useRef(false)
|
||||
const didInitialFocus = useRef(false)
|
||||
const prevFocusKey = useRef(focusKey)
|
||||
const built = useRef(false)
|
||||
|
||||
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
|
||||
if (coords.length === 0) return
|
||||
@@ -493,7 +475,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
* (#1982). MapViewGL never had the nudge, so this also brings the two
|
||||
* renderers back into agreement.
|
||||
*/
|
||||
map.fitBounds(bounds, { ...padding, maxZoom: 16, animate: true })
|
||||
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
|
||||
}
|
||||
} catch {}
|
||||
}, [map, paddingOpts])
|
||||
@@ -534,6 +516,10 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
// The first run is the map being built. A frame handed over by then is where the map
|
||||
// opens, so it lands at once instead of flying in from the window-sized opening guess.
|
||||
const opening = !built.current
|
||||
built.current = true
|
||||
if (!focusPoints?.length) return
|
||||
if (focusKey !== undefined) {
|
||||
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current
|
||||
@@ -543,7 +529,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
}
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
|
||||
fitTo(focusPoints, { ...(fitPadding ? leafletPadding(fitPadding) : paddingOpts), animate: !opening })
|
||||
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
|
||||
@@ -7,8 +7,8 @@ import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
// The planner-waypoint and location-search camera cases of MapViewGL, and the tour
|
||||
// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
||||
// The planner-waypoint, location-search and antimeridian camera cases of MapViewGL, and
|
||||
// the tour map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
@@ -356,6 +356,31 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('frames handed-over points across the antimeridian the short way, as the opening camera does', async () => {
|
||||
loadOnAttach();
|
||||
// Fiji, Tonga and Samoa, a collection's list: a box from plain min and max would run
|
||||
// from -175 to 178, round the far side of the world, with each pin on an edge.
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="1,2,3"
|
||||
focusPoints={[
|
||||
[-18.14, 178.44],
|
||||
[-21.14, -175.2],
|
||||
[-13.83, -171.76],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
const framed = glBounds.instances.find((bounds) => bounds === glMap.fitBounds.mock.calls[0][0]);
|
||||
const lngs = framed?.extend.mock.calls.map(([lngLat]) => Math.round(lngLat[0] * 100) / 100);
|
||||
expect(lngs).toEqual([178.44, 184.8, 188.24]);
|
||||
});
|
||||
|
||||
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
|
||||
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
|
||||
async (provider) => {
|
||||
|
||||
@@ -944,7 +944,7 @@ describe('MapViewGL', () => {
|
||||
/>,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 0 })]])
|
||||
glMap.fitBounds.mockClear()
|
||||
const mapConstructions = (maplibregl.Map as any).mock.calls.length
|
||||
const retainedCenter = { lng: 135.8, lat: 35.2 }
|
||||
@@ -979,7 +979,7 @@ describe('MapViewGL', () => {
|
||||
/>,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 400 })]])
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack,
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
@@ -2091,7 +2091,7 @@ export function MapViewGL({
|
||||
didInitialFocusRef.current = true
|
||||
}
|
||||
const bounds = new gl.LngLatBounds()
|
||||
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
alongShortestArc(focusPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
// A day fit still waiting on its route must not overwrite this a moment later.
|
||||
pendingRouteFitRef.current = null
|
||||
try {
|
||||
@@ -2099,7 +2099,7 @@ export function MapViewGL({
|
||||
padding: fitPadding ?? paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
duration: mapReady ? 400 : 0, // handed over while the map is built: it opens there, no flight in
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -426,6 +426,23 @@ describe('calculateRouteWithLegs', () => {
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-035b: a route answered once is still there after a reload, without asking the router (#2794)', async () => {
|
||||
let hits = 0
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => { hits++; return HttpResponse.json(buildLegsResponse()) }))
|
||||
const wps = freshWaypoints()
|
||||
const first = await calculateRouteWithLegs(wps)
|
||||
const { offlineDb } = await import('../../db/offlineDb')
|
||||
await vi.waitFor(async () => expect(await offlineDb.routeCache.count()).toBeGreaterThan(0))
|
||||
|
||||
// A reload: the module, and with it the cache in memory, starts over.
|
||||
vi.resetModules()
|
||||
const reloaded = await import('./RouteCalculator')
|
||||
const again = await reloaded.calculateRouteWithLegs(wps)
|
||||
|
||||
expect(hits).toBe(1)
|
||||
expect(again).toEqual(first)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-036: switching the distance unit re-fetches instead of reusing stale text (#1300)', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildLegsResponse())))
|
||||
const wps = freshWaypoints()
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { cacheRoute, rememberRoute, routeInMemory, storedRoute } from './routeStore'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
|
||||
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
@@ -196,10 +197,6 @@ function retryAfterMs(response: Response): number | null {
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
const ROUTE_CACHE_MAX = 200
|
||||
|
||||
/**
|
||||
* A route profile is either one of the built-in OSRM profiles or a plugin profile
|
||||
@@ -540,7 +537,10 @@ export async function calculateRouteWithLegs(
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const cacheKey = `${plainKey}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
// A plugin route stays in memory: its answer depends on the trip and the day,
|
||||
// and may carry live data such as charging stops. The rest is kept on the device
|
||||
// too, so a trip that shows its whole route opens drawn (#2794).
|
||||
const cached = pluginScope ? routeInMemory(cacheKey) : await storedRoute(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
@@ -552,7 +552,7 @@ export async function calculateRouteWithLegs(
|
||||
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
|
||||
// than blank, and the answer says that nothing was avoided, with the reason, so the
|
||||
// rail flags the day instead of implying the setting held on a road nobody weighed.
|
||||
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
|
||||
const plain = (await storedRoute(plainKey)) ?? await osrmWithLegs(waypoints, coords, profile, signal)
|
||||
cacheRoute(plainKey, plain)
|
||||
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
|
||||
}
|
||||
@@ -591,11 +591,7 @@ export async function calculateRouteWithLegs(
|
||||
legs,
|
||||
...(route.viaPoints.length ? { vias: route.viaPoints } : {}),
|
||||
}
|
||||
routeCache.set(cacheKey, result)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
rememberRoute(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -638,15 +634,6 @@ async function osrmWithLegs(
|
||||
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
routeCache.set(key, route)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain from the second engine, weighted away from some road classes.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { routeCacheRepo } from '../../repo/routeCacheRepo';
|
||||
import type { RouteWithLegs } from '../../types';
|
||||
|
||||
/**
|
||||
* Route answers by RouteCalculator's cache key (profile, unit, waypoints,
|
||||
* avoided roads). Routes are stable, so this keeps the router from being asked
|
||||
* again on every day switch or reorder: in memory for this visit, and on the
|
||||
* device for the next one, so a trip that shows its whole route opens drawn
|
||||
* instead of routing every leg again, one a second (#2794).
|
||||
*/
|
||||
const inMemory = new Map<string, RouteWithLegs>();
|
||||
const MEMORY_MAX = 200;
|
||||
|
||||
/** Keeps a route for this visit only, dropping the oldest past the limit. */
|
||||
export function rememberRoute(key: string, route: RouteWithLegs): void {
|
||||
inMemory.set(key, route);
|
||||
if (inMemory.size > MEMORY_MAX) {
|
||||
const oldest = inMemory.keys().next().value;
|
||||
if (oldest !== undefined) inMemory.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** The route this visit already holds, without asking the device. */
|
||||
export function routeInMemory(key: string): RouteWithLegs | undefined {
|
||||
return inMemory.get(key);
|
||||
}
|
||||
|
||||
/** Keeps a route the router just answered, in memory and on the device. */
|
||||
export function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
rememberRoute(key, route);
|
||||
void routeCacheRepo.put(key, route);
|
||||
}
|
||||
|
||||
/** The route kept in memory, else the one kept on the device, which then stays in memory too. */
|
||||
export async function storedRoute(key: string): Promise<RouteWithLegs | null> {
|
||||
const held = inMemory.get(key);
|
||||
if (held) return held;
|
||||
const kept = await routeCacheRepo.get<RouteWithLegs>(key);
|
||||
if (kept) rememberRoute(key, kept);
|
||||
return kept;
|
||||
}
|
||||
@@ -5,19 +5,22 @@ import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { PlaceSuggestion, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
import type { MapMatch } from '../../repo/mapMatchRepo'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
/** A picked suggestion's spot on the map, for a form that saves where the address is (an imported stay). */
|
||||
onPick?: (match: MapMatch) => void
|
||||
}
|
||||
|
||||
// Free-text address input with location autocomplete, backed by the same maps
|
||||
// search as LocationSelect. Unlike LocationSelect the typed text is
|
||||
// authoritative: every keystroke reaches the parent so a hand-written address
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
export default function AddressInput({ value, onChange, placeholder, className, onPick }: Props) {
|
||||
const { locale } = useTranslation()
|
||||
const placeLang = usePlaceLanguage()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
@@ -60,6 +63,9 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
// A response still on its way must not repopulate the list behind the pick.
|
||||
reqIdRef.current++
|
||||
onChange(r.address || r.name || '')
|
||||
if (onPick && Number.isFinite(r.lat) && Number.isFinite(r.lng)) {
|
||||
onPick({ lat: r.lat, lng: r.lng, label: [r.name, r.address].filter(Boolean).join(', ') })
|
||||
}
|
||||
setOpen(false)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
|
||||
@@ -118,9 +118,10 @@ export function DayPlanPlaceSubline({
|
||||
const description = place.description || place.address || cat?.name;
|
||||
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
|
||||
return (
|
||||
// One line like the row's name: when the hover controls come out, the description
|
||||
// gives way and the pills keep their place instead of wrapping onto a second line.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
// The description stays on one line and gives way first. The pills wrap rather
|
||||
// than run out of the row: a tour carries a time, its duration and its distance,
|
||||
// and in the narrow day column the last of them used to be cut off unseen.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 overflow-hidden">
|
||||
{place.place_time && dayRowTour && (
|
||||
// TIME-02: a tour's end, typed or worked out from its start and its time.
|
||||
<TimePill quiet>
|
||||
|
||||
@@ -201,9 +201,9 @@ describe('DayPlanSidebar', () => {
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
|
||||
expect(tourPreview).toHaveTextContent(description);
|
||||
// One line like the row's name, so hover controls make the description give way instead of wrapping.
|
||||
// The description keeps to one line; the pills beside it wrap rather than run out of the row.
|
||||
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
|
||||
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
|
||||
expect(tourPreview.parentElement).toHaveClass('flex-wrap', 'overflow-hidden');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useReservationForm } from './useReservationForm'
|
||||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
|
||||
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
||||
import { useBookingFileAttach } from './useBookingFileAttach'
|
||||
import StayPinStatus from './StayPinStatus'
|
||||
|
||||
const EMPTY_FORM = {
|
||||
title: '', type: 'other', status: 'pending',
|
||||
@@ -81,7 +82,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const {
|
||||
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
|
||||
isEndBeforeStart, endTimeBeforeStartTime, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
|
||||
} = useReservationForm(EMPTY_FORM, days, places)
|
||||
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
|
||||
|
||||
@@ -102,17 +103,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
|
||||
setTravelerIds(travelerIdsOf(reservation))
|
||||
stayPin.reset()
|
||||
if (reservation) {
|
||||
setForm({ ...reservationFieldsFrom(reservation, accommodations, places, true), assignment_id: reservation.assignment_id || '' })
|
||||
} else if (prefill) {
|
||||
// Review-before-save: populate from a parsed import item, stay in create mode.
|
||||
const hotelPlaceId = matchPlaceId(prefill._venue?.name || prefill.title)
|
||||
setForm({
|
||||
...reservationFieldsFromPrefill(prefill, days, true),
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
place_id: '',
|
||||
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
|
||||
hotel_place_id: hotelPlaceId,
|
||||
})
|
||||
// A stay the trip does not hold yet: find it on the map now, so the review
|
||||
// says whether it will show there before it is saved.
|
||||
if (prefill.type === 'hotel' && !hotelPlaceId) stayPin.lookUp(prefill._venue?.name || prefill.title, prefill._venue?.address)
|
||||
// Seed the booking's Files with the document this item was parsed from.
|
||||
setPendingFiles(prefill._sourceFiles ?? [])
|
||||
} else {
|
||||
@@ -362,8 +368,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
<EditorField label={t('reservations.locationAddress')}>
|
||||
<AddressInput value={form.hotel_address} onChange={v => set('hotel_address', v)}
|
||||
onPick={stayPin.pick}
|
||||
placeholder={t('reservations.locationPlaceholder')} className={INPUT} />
|
||||
</EditorField>
|
||||
<StayPinStatus
|
||||
pin={stayPin.pin}
|
||||
hidden={!!form.hotel_place_id}
|
||||
onRetry={() => stayPin.lookUp(form.title, form.hotel_address)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import StayPinStatus from './StayPinStatus';
|
||||
import type { StayPin } from './useStayPin';
|
||||
|
||||
function show(pin: StayPin, onRetry = vi.fn()) {
|
||||
render(createElement(TranslationProvider, null, createElement(StayPinStatus, { pin, onRetry })));
|
||||
return onRetry;
|
||||
}
|
||||
|
||||
describe('StayPinStatus', () => {
|
||||
it('FE-STAY-PIN-STATUS-001: shows nothing before a lookup', () => {
|
||||
const { container } = render(
|
||||
createElement(
|
||||
TranslationProvider,
|
||||
null,
|
||||
createElement(StayPinStatus, { pin: { status: 'idle' }, onRetry: vi.fn() })
|
||||
)
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-001b: a stay already linked to a place of the trip shows nothing', () => {
|
||||
const { container } = render(
|
||||
createElement(
|
||||
TranslationProvider,
|
||||
null,
|
||||
createElement(StayPinStatus, { pin: { status: 'missing' }, hidden: true, onRetry: vi.fn() })
|
||||
)
|
||||
);
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-002: says it is looking, then where it found the stay', () => {
|
||||
show({ status: 'searching' });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Looking for it on the map');
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-003: a found stay names the match, so a wrong one is plain', () => {
|
||||
show({ status: 'found', match: { lat: 1, lng: 2, label: 'Hotel Gracery Shinjuku, Tokyo' } });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('On the map');
|
||||
expect(screen.getByText('Hotel Gracery Shinjuku, Tokyo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-STATUS-004: a missing stay says so and offers to search again', () => {
|
||||
const onRetry = show({ status: 'missing' });
|
||||
expect(screen.getByText('Not on the map yet')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /search again/i }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MapPin, MapPinOff, RotateCw } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { StayPin } from './useStayPin';
|
||||
|
||||
interface StayPinStatusProps {
|
||||
pin: StayPin;
|
||||
/** A stay already linked to a place of the trip shows on the map through that place. */
|
||||
hidden?: boolean;
|
||||
/** Asks the map again with what the form now holds. */
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an imported stay will show on the map, under its address in the
|
||||
* review: being looked up, found (and where, so a wrong match is plain), or not
|
||||
* found, with a way to search again before it is saved without a pin. Shared by
|
||||
* the desktop booking dialog and the phone's booking sheet.
|
||||
*/
|
||||
export default function StayPinStatus({ pin, hidden = false, onRetry }: StayPinStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
if (hidden || pin.status === 'idle') return null;
|
||||
|
||||
if (pin.status === 'searching') {
|
||||
return (
|
||||
<div role="status" className="flex items-center gap-2 px-1 text-caption text-content-muted">
|
||||
<span className="h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-edge border-t-content-muted" />
|
||||
{t('reservations.import.pinSearching')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pin.status === 'found') {
|
||||
return (
|
||||
<div role="status" className="flex min-w-0 items-center gap-2 rounded-lg bg-success-soft px-3 py-2 text-caption">
|
||||
<MapPin size={14} strokeWidth={2.2} className="flex-none text-success" />
|
||||
<span className="flex-none font-semibold text-success">{t('reservations.import.pinFound')}</span>
|
||||
<span className="min-w-0 truncate text-content-secondary" title={pin.match.label}>
|
||||
{pin.match.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="status" className="flex items-start gap-3 rounded-xl bg-warning-soft px-3.5 py-3">
|
||||
<MapPinOff size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 text-body font-semibold text-content">{t('reservations.import.pinMissingTitle')}</p>
|
||||
<p className="m-0 mt-0.5 text-caption text-content-muted">{t('reservations.import.pinMissingBody')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-edge bg-surface-card px-2.5 py-1 text-caption font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
<RotateCw size={12} strokeWidth={2.2} />
|
||||
{t('reservations.import.pinRetry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -287,6 +287,8 @@ export interface ReservationSaveOptions {
|
||||
isEdit: boolean;
|
||||
/** The desktop also saves the end of the check-in window. */
|
||||
withCheckInEnd: boolean;
|
||||
/** Where the review found an imported stay on the map, so the save creates its place right there. */
|
||||
venuePin?: { lat: number; lng: number } | null;
|
||||
}
|
||||
|
||||
export type ReservationSaveData = Record<string, unknown> & { title: string };
|
||||
@@ -344,7 +346,11 @@ export function reservationSaveData(
|
||||
// import): the save handler geocodes it and creates the place.
|
||||
venue:
|
||||
!form.hotel_place_id && (form.hotel_address || form.title)
|
||||
? { name: form.title, address: form.hotel_address || null }
|
||||
? {
|
||||
name: form.title,
|
||||
address: form.hotel_address || null,
|
||||
...(opts.venuePin ? { lat: opts.venuePin.lat, lng: opts.venuePin.lng } : {}),
|
||||
}
|
||||
: null,
|
||||
// The typed address, so the save handler can write it through to a linked
|
||||
// place; an edited address used to be silently dropped (#1496).
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
withStartDate,
|
||||
withStartTime,
|
||||
} from './reservationFormModel';
|
||||
import { useStayPin } from './useStayPin';
|
||||
|
||||
/**
|
||||
* The booking form behind the desktop ReservationModal and the phone's booking
|
||||
@@ -39,6 +40,8 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
|
||||
|
||||
const set = (field: keyof F, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }));
|
||||
const toggleTraveler = (id: number) => setTravelerIds((prev) => toggledTraveler(prev, id));
|
||||
// Where an imported stay will land on the map, looked up while it is reviewed.
|
||||
const stayPin = useStayPin();
|
||||
|
||||
// Restrict non-hotel booking dates to the trip's span (#1662). Hotels already
|
||||
// constrain to trip days via their day dropdowns.
|
||||
@@ -71,6 +74,7 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
|
||||
pickHotelPlace: (value: string | number) => setForm((prev) => withHotelPlace(prev, value, places)),
|
||||
pickHotelStart: (value: string | number) => setForm((prev) => withHotelStart(prev, value, days)),
|
||||
pickHotelEnd: (value: string | number) => setForm((prev) => withHotelEnd(prev, value, days)),
|
||||
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, opts),
|
||||
stayPin,
|
||||
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, { ...opts, venuePin: stayPin.match }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { findOnMap } from '../../repo/mapMatchRepo';
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
import { useStayPin } from './useStayPin';
|
||||
|
||||
vi.mock('../../hooks/usePlaceLanguage', () => ({ usePlaceLanguage: () => 'en' }));
|
||||
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../../repo/mapMatchRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../repo/mapMatchRepo')>()),
|
||||
findOnMap: vi.fn(),
|
||||
}));
|
||||
|
||||
const SPOT = { lat: 35.69, lng: 139.7, label: 'Hotel Gracery Shinjuku' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('useStayPin', () => {
|
||||
it('FE-STAY-PIN-001: looks the stay up by name and address and says where it found it', async () => {
|
||||
vi.mocked(findOnMap).mockResolvedValueOnce(SPOT);
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
|
||||
act(() => result.current.lookUp('Hotel Gracery', '1-19-1 Kabukicho'));
|
||||
expect(result.current.pin).toEqual({ status: 'searching' });
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'found', match: SPOT }));
|
||||
expect(result.current.match).toEqual(SPOT);
|
||||
expect(findOnMap).toHaveBeenCalledWith(
|
||||
['Hotel Gracery 1-19-1 Kabukicho', 'Hotel Gracery', '1-19-1 Kabukicho'],
|
||||
'en'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-002: nothing found, a failed search and an empty name all read as not on the map', async () => {
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
vi.mocked(findOnMap).mockResolvedValueOnce(null);
|
||||
act(() => result.current.lookUp('Ryokan', null));
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
|
||||
expect(result.current.match).toBeNull();
|
||||
|
||||
vi.mocked(findOnMap).mockRejectedValueOnce(new Error('down'));
|
||||
act(() => result.current.lookUp('Ryokan', null));
|
||||
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
|
||||
|
||||
act(() => result.current.lookUp('', ''));
|
||||
expect(result.current.pin).toEqual({ status: 'missing' });
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-003: a later lookup or a hand-picked spot wins over an answer still on its way', async () => {
|
||||
let answer: (m: typeof SPOT | null) => void = () => undefined;
|
||||
vi.mocked(findOnMap).mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
act(() => result.current.lookUp('Hotel', 'Somewhere'));
|
||||
const picked = { lat: 1, lng: 2, label: 'Picked' };
|
||||
act(() => result.current.pick(picked));
|
||||
await act(async () => answer(SPOT));
|
||||
expect(result.current.pin).toEqual({ status: 'found', match: picked });
|
||||
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
});
|
||||
|
||||
it('FE-STAY-PIN-004: offline there is nothing to ask, so the review shows nothing', () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
const { result } = renderHook(() => useStayPin());
|
||||
act(() => result.current.lookUp('Hotel', 'Somewhere'));
|
||||
expect(result.current.pin).toEqual({ status: 'idle' });
|
||||
expect(findOnMap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
|
||||
import { findOnMap, type MapMatch, stayQueries } from '../../repo/mapMatchRepo';
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
|
||||
/**
|
||||
* Where an imported stay will sit on the map, as the review shows it: not asked
|
||||
* yet (or offline, where there is nothing to ask), being looked up, found, or
|
||||
* not found at all.
|
||||
*/
|
||||
export type StayPin =
|
||||
{ status: 'idle' } | { status: 'searching' } | { status: 'found'; match: MapMatch } | { status: 'missing' };
|
||||
|
||||
/**
|
||||
* The map spot of a stay reviewed from an import, looked up before it is saved
|
||||
* so the review can say whether it will show on the map. A stay the search did
|
||||
* not find used to be saved without coordinates and simply never appeared.
|
||||
* A later lookup always wins over an earlier one still on its way.
|
||||
*/
|
||||
export function useStayPin() {
|
||||
const lang = usePlaceLanguage();
|
||||
const [pin, setPin] = useState<StayPin>({ status: 'idle' });
|
||||
const ticket = useRef(0);
|
||||
useEffect(
|
||||
() => () => {
|
||||
ticket.current++;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const lookUp = (name: string | null | undefined, address: string | null | undefined) => {
|
||||
const mine = ++ticket.current;
|
||||
const queries = stayQueries(name ?? '', address);
|
||||
if (isEffectivelyOffline()) {
|
||||
setPin({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
if (queries.length === 0) {
|
||||
setPin({ status: 'missing' });
|
||||
return;
|
||||
}
|
||||
setPin({ status: 'searching' });
|
||||
findOnMap(queries, lang).then(
|
||||
(match) => {
|
||||
if (mine === ticket.current) setPin(match ? { status: 'found', match } : { status: 'missing' });
|
||||
},
|
||||
() => {
|
||||
if (mine === ticket.current) setPin({ status: 'missing' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
pin,
|
||||
/** The spot to save the stay at, once one is known. */
|
||||
match: pin.status === 'found' ? pin.match : null,
|
||||
lookUp,
|
||||
/** A spot the reader picked by hand, which beats any lookup. */
|
||||
pick: (match: MapMatch) => {
|
||||
ticket.current++;
|
||||
setPin({ status: 'found', match });
|
||||
},
|
||||
reset: () => {
|
||||
ticket.current++;
|
||||
setPin({ status: 'idle' });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import AccountDemoLock from './AccountDemoLock';
|
||||
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
function renderCards() {
|
||||
return render(
|
||||
<AccountDemoLock>
|
||||
<button type="button">Save profile</button>
|
||||
</AccountDemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AccountDemoLock', () => {
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-001: the shared demo account sees its cards under the lock, with the reason', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
const { container } = renderCards();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(screen.queryByRole('button', { name: 'Save profile' })).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
|
||||
screen.getByRole('button', { hidden: true, name: 'Save profile' })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-002: the admin of a demo instance gets the cards as they are', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
|
||||
const { container } = renderCards();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
|
||||
expect(container.querySelector('[data-demo-lock]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-003: the demo address on an instance that is not a demo is left alone', () => {
|
||||
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
renderCards();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import DemoLock from '../Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock';
|
||||
|
||||
/**
|
||||
* The account cards under the demo lock, on the desktop tab and the phone
|
||||
* screen alike. Every visitor of a demo instance signs in as the one shared
|
||||
* demo account, so its profile, password, two factor setup and passkeys stay
|
||||
* on view but out of reach, while the admin of a demo instance and every
|
||||
* account of a real one get the cards as they are. Both views wrap their
|
||||
* cards in this, so whom the lock holds and the reason it gives are written
|
||||
* once.
|
||||
*/
|
||||
export default function AccountDemoLock({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const locked = useDemoAccountLock();
|
||||
return (
|
||||
<DemoLock locked={locked} reason={t('demo.lock.account')}>
|
||||
{children}
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
@@ -395,10 +395,20 @@ describe('AccountTab – MFA', () => {
|
||||
expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-036: MFA section shows demoBlocked message in demo mode', () => {
|
||||
seedStore(useAuthStore, { demoMode: true });
|
||||
it('FE-COMP-ACCOUNT-036: the shared demo account sees the whole tab under the demo lock', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
|
||||
const { container } = render(<AccountTab />);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
|
||||
screen.getByRole('button', { hidden: true, name: /Set up authenticator/ }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-036b: the admin of a demo instance keeps the tab unlocked, 2FA included', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin', mfa_enabled: false }) });
|
||||
render(<AccountTab />);
|
||||
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Set up authenticator/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
|
||||
} from './settingsKit'
|
||||
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
|
||||
import AccountDemoLock from './AccountDemoLock'
|
||||
|
||||
/** The small turning ring a button shows while its request is on the way. */
|
||||
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
|
||||
@@ -71,6 +72,8 @@ export default function AccountTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The shared demo account: everything here is shown, nothing can be changed. */}
|
||||
<AccountDemoLock>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
|
||||
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
|
||||
@@ -221,10 +224,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
|
||||
{demoMode ? (
|
||||
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
@@ -357,12 +357,12 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Section>
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeysSection demoMode={demoMode} />
|
||||
</AccountDemoLock>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
<DialogShell
|
||||
|
||||
@@ -483,3 +483,18 @@ describe('DisplaySettingsTab week start (#2029)', () => {
|
||||
expect(await screen.findByText('Save failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DisplaySettingsTab – the demo account', () => {
|
||||
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), isLoaded: true });
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The demo always runs in English.');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-DEMO-002: any other account of a demo instance picks its language freely', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDes
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
|
||||
const SELECT_BOX = 'w-[260px] max-w-full'
|
||||
@@ -25,6 +27,7 @@ const SELECT_BOX = 'w-[260px] max-w-full'
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, save } = useSettingSaver()
|
||||
const { t, locale } = useTranslation()
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
|
||||
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
@@ -123,7 +126,9 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
|
||||
The shared demo account always runs in English. */}
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
|
||||
<SettingRow label={t('settings.language')} stacked>
|
||||
<div className="hidden sm:block">
|
||||
<ChoiceChips
|
||||
@@ -162,6 +167,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</DemoLock>
|
||||
|
||||
{/* Place names (#1799): searches can answer in another language than the app. */}
|
||||
<SettingRow
|
||||
|
||||
@@ -18,10 +18,13 @@ import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, Setting
|
||||
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
|
||||
import { useIntegrationGates } from './useIntegrationGates'
|
||||
import WandererConnectionSection from './WandererConnectionSection'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const S = useIntegrations()
|
||||
const managed = S.managed
|
||||
const demoLocked = useDemoAccountLock()
|
||||
return (
|
||||
<>
|
||||
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
|
||||
@@ -42,9 +45,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
|
||||
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
|
||||
one should not have to read past a section about AI assistants. */}
|
||||
<ApiKeysSection />
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
|
||||
<ApiKeysSection />
|
||||
</DemoLock>
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
||||
{S.mcpEnabled && (
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
|
||||
<IntegrationsMcpSection S={S} />
|
||||
</DemoLock>
|
||||
)}
|
||||
<McpTokenModals S={S} />
|
||||
<OAuthClientModals S={S} />
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
@@ -33,6 +34,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAdmin, buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
@@ -51,6 +52,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
|
||||
repo.disconnect.mockResolvedValue(undefined);
|
||||
repo.chatgptStart.mockResolvedValue({
|
||||
@@ -140,4 +143,13 @@ describe('AiConnectionSection', () => {
|
||||
const { container } = render(<AiConnectionSection />);
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-CONNECTION-008: the shared demo account sees its own connection locked, and no admin hint', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<AiConnectionSection />);
|
||||
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
|
||||
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { hidden: true, name: 'Connect your own provider' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
|
||||
import DemoLock from '../../Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../../Demo/useDemoLock';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
@@ -202,6 +204,25 @@ function AssistantBehaviour() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the shared demo account would connect a provider of its own: shown, and locked. */
|
||||
function LockedOwnConnection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DemoLock locked reason={t('demo.lock.ai')} size="row">
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('demo.lock.aiTitle')}
|
||||
control={
|
||||
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
/** The connect panel, opened on purpose or because nothing is connected yet. */
|
||||
function ConnectPanel({ S }: { S: AiConnections }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -254,6 +275,7 @@ export default function AiConnectionSection({
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const S = useAiConnections();
|
||||
const demoLocked = useDemoAccountLock();
|
||||
const text = aiSectionText(assistant, parsing);
|
||||
if (S.view === 'hidden') return null;
|
||||
const own = S.own;
|
||||
@@ -277,7 +299,8 @@ export default function AiConnectionSection({
|
||||
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
|
||||
{S.view === 'instance' && <InstanceConnection S={S} />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
|
||||
{S.view === 'adminOnly' && !demoLocked && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
|
||||
{demoLocked && S.view !== 'loading' && <LockedOwnConnection />}
|
||||
|
||||
{S.panel && <ConnectPanel S={S} />}
|
||||
<SectionFooter S={S} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-AI-CONNECT-MODEL-001 to FE-AI-CONNECT-MODEL-007: the pure rules behind connecting an AI provider.
|
||||
// FE-AI-CONNECT-MODEL-001 to FE-AI-CONNECT-MODEL-008: the pure rules behind connecting an AI provider.
|
||||
import type { AiConnectionView } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
connectErrorKey,
|
||||
httpHostOf,
|
||||
isPublicHttpsUrl,
|
||||
openrouterReturnCode,
|
||||
refusalCode,
|
||||
rememberOpenrouterFlow,
|
||||
repairModeOf,
|
||||
@@ -93,4 +94,14 @@ describe('aiConnectModel', () => {
|
||||
expect(src).not.toContain('<');
|
||||
expect(decodeURIComponent(src.split(',')[1])).toBe('<svg><path d="M0 0"/></svg>');
|
||||
});
|
||||
|
||||
it('FE-AI-CONNECT-MODEL-008: reads the OpenRouter return with the code after an & or after a second ?', () => {
|
||||
const read = (query: string) => openrouterReturnCode(new URLSearchParams(query));
|
||||
expect(read('tab=integrations&ai=openrouter&code=abc')).toBe('abc');
|
||||
expect(read('tab=integrations&ai=openrouter?code=abc')).toBe('abc');
|
||||
expect(read('tab=addons&ai=openrouter?code=abc&state=x')).toBe('abc');
|
||||
expect(read('ai=openrouter')).toBeNull();
|
||||
expect(read('tab=integrations')).toBeUndefined();
|
||||
expect(read('ai=chatgpt&code=abc')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,26 @@ export function repairModeOf(view: AiConnectionView): AiConnectMode | null {
|
||||
return view.kind === 'custom' ? 'custom' : 'key';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pasted ChatGPT callback ended this sign-in rather than an earlier
|
||||
* one: its `state` is the one the sign-in address carried. The server takes a
|
||||
* sign-in only once, so an address from an earlier attempt still on the
|
||||
* clipboard would use up the one in progress. Without a state on either side
|
||||
* the server decides.
|
||||
*/
|
||||
export function callbackMatchesFlow(callback: string, authorizeUrl: string): boolean {
|
||||
const stateOf = (address: string) => {
|
||||
try {
|
||||
return new URL(address.trim()).searchParams.get('state');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const expected = stateOf(authorizeUrl);
|
||||
const pasted = stateOf(callback);
|
||||
return !expected || !pasted || pasted === expected;
|
||||
}
|
||||
|
||||
// The OpenRouter sign-in leaves the page and comes back with a code only; the
|
||||
// flow it belongs to waits here for that one return. Tab-scoped on purpose.
|
||||
const OPENROUTER_FLOW_KEY = 'trek.ai.openrouterFlow';
|
||||
@@ -79,6 +99,21 @@ export function takeOpenrouterFlow(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The code an OpenRouter sign-in came back with, or `undefined` when the page
|
||||
* was not opened by one. The return address already carries a query
|
||||
* (`?tab=…&ai=openrouter`), and OpenRouter may add its code with a second `?`
|
||||
* rather than an `&`, which leaves it inside the `ai` value
|
||||
* (`ai=openrouter?code=…`); both shapes count.
|
||||
*/
|
||||
export function openrouterReturnCode(params: URLSearchParams): string | null | undefined {
|
||||
const ai = params.get('ai');
|
||||
if (ai === null) return undefined;
|
||||
const [marker, rest] = ai.split('?', 2);
|
||||
if (marker !== 'openrouter') return undefined;
|
||||
return params.get('code') ?? new URLSearchParams(rest ?? '').get('code');
|
||||
}
|
||||
|
||||
const PRIVATE_V4 = [/^10\./, /^127\./, /^169\.254\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^0\./];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-013: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
|
||||
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-015: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
|
||||
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
@@ -249,6 +249,52 @@ describe('useChatgptConnect', () => {
|
||||
expect(readText).toHaveBeenCalledTimes(1);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-CONNECT-014: the same address handed in twice at once is sent once, so the sign-in is not used up', async () => {
|
||||
let answer: (value: unknown) => void = () => undefined;
|
||||
repo.chatgptFinish.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
|
||||
const onConnected = vi.fn();
|
||||
const { result } = renderHook(() => useChatgptConnect({ active: true, onConnected }), { wrapper: wrapperAt() });
|
||||
await waitFor(() => expect(result.current.flow).not.toBeNull());
|
||||
act(() => {
|
||||
result.current.paste(CALLBACK);
|
||||
result.current.paste(CALLBACK);
|
||||
});
|
||||
await act(async () => answer({}));
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
|
||||
expect(repo.chatgptFinish).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-CONNECT-015: an address from an earlier sign-in is not sent, and the one in progress stays usable', async () => {
|
||||
repo.chatgptStart.mockResolvedValue({ ...FLOW, authorizeUrl: 'https://auth.openai.com/oauth/authorize?state=s2' });
|
||||
const readText = vi.fn(async () => CALLBACK);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText } });
|
||||
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true });
|
||||
const onConnected = vi.fn();
|
||||
const { result } = renderHook(() => useChatgptConnect({ active: true, onConnected }), { wrapper: wrapperAt() });
|
||||
await waitFor(() => expect(result.current.flow).not.toBeNull());
|
||||
|
||||
// Back from the sign-in window with the old address still on the clipboard: nothing is sent.
|
||||
act(() => result.current.markOpened());
|
||||
fireEvent.focus(window);
|
||||
await waitFor(() => expect(readText).toHaveBeenCalled());
|
||||
expect(repo.chatgptFinish).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
// Pasted by hand it is refused here, and the address this sign-in ended on still goes through.
|
||||
act(() => {
|
||||
result.current.paste(CALLBACK);
|
||||
});
|
||||
expect(result.current.error).toEqual({ messageKey: 'assistant.connect.chatgptWrong', code: 'wrong_url' });
|
||||
const current = CALLBACK.replace('state=s1', 'state=s2');
|
||||
act(() => {
|
||||
result.current.paste(current);
|
||||
});
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
|
||||
expect(repo.chatgptFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, pastedUrl: current });
|
||||
expect(repo.chatgptStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenRouter', () => {
|
||||
@@ -280,4 +326,19 @@ describe('OpenRouter', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
|
||||
expect(repo.openrouterFinish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-CONNECT-012b: a code OpenRouter added with a second question mark still finishes the sign-in', async () => {
|
||||
rememberOpenrouterFlow(FLOW.flowId);
|
||||
const onConnected = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useOpenrouterReturn(onConnected);
|
||||
return useLocation();
|
||||
},
|
||||
{ wrapper: wrapperAt('/settings?tab=integrations&ai=openrouter?code=or-code') }
|
||||
);
|
||||
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
|
||||
expect(repo.openrouterFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, code: 'or-code' });
|
||||
expect(result.current.search).toBe('?tab=integrations');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useTranslation } from '../../../i18n';
|
||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
||||
import { detectKeyKind, isChatgptCallback, providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { connectErrorKey, httpHostOf, refusalCode, rememberOpenrouterFlow, takeOpenrouterFlow } from './aiConnectModel';
|
||||
import {
|
||||
callbackMatchesFlow,
|
||||
connectErrorKey,
|
||||
httpHostOf,
|
||||
openrouterReturnCode,
|
||||
refusalCode,
|
||||
rememberOpenrouterFlow,
|
||||
takeOpenrouterFlow,
|
||||
} from './aiConnectModel';
|
||||
|
||||
/**
|
||||
* The ways to connect a provider, one hook each, behind `AiConnectPanel`: a
|
||||
@@ -205,6 +213,10 @@ export function useChatgptConnect({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<ConnectError | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Set at once, unlike `busy`: the clipboard read on return and the user's own
|
||||
// paste can hand in the same address within one render, and the server takes
|
||||
// a sign-in only once, so the second would come back as expired.
|
||||
const finishing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -232,8 +244,8 @@ export function useChatgptConnect({
|
||||
|
||||
const finish = async (raw: string) => {
|
||||
const url = raw.trim();
|
||||
if (!flow || busy) return;
|
||||
if (!isChatgptCallback(url)) {
|
||||
if (!flow || finishing.current) return;
|
||||
if (!isChatgptCallback(url) || !callbackMatchesFlow(url, flow.authorizeUrl)) {
|
||||
setError({ messageKey: 'assistant.connect.chatgptWrong', code: 'wrong_url' });
|
||||
return;
|
||||
}
|
||||
@@ -242,6 +254,7 @@ export function useChatgptConnect({
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
finishing.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -253,6 +266,7 @@ export function useChatgptConnect({
|
||||
setError({ messageKey: connectErrorKey(err), code });
|
||||
if (code === 'expired') restart();
|
||||
} finally {
|
||||
finishing.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -280,10 +294,12 @@ export function useChatgptConnect({
|
||||
|
||||
// Back from the sign-in window: the address the user just copied is taken
|
||||
// off the clipboard where the browser allows it, else the field has the cursor.
|
||||
// An address left over from an earlier sign-in is not sent: it would use up this one.
|
||||
useOnReturnToTab(opened, () => {
|
||||
if (pasted.trim() || busy) return;
|
||||
readClipboard((text) => {
|
||||
if (!paste(text)) focusField();
|
||||
if (flow && !callbackMatchesFlow(text, flow.authorizeUrl)) focusField();
|
||||
else if (!paste(text)) focusField();
|
||||
}, focusField);
|
||||
});
|
||||
|
||||
@@ -351,9 +367,9 @@ export function useOpenrouterReturn(onConnected: () => void) {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current || params.get('ai') !== 'openrouter') return;
|
||||
const code = openrouterReturnCode(params);
|
||||
if (handled.current || code === undefined) return;
|
||||
handled.current = true;
|
||||
const code = params.get('code');
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } 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(() => ({
|
||||
@@ -61,6 +63,8 @@ function renderConnections(phone = false) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
// The card shows 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.mockResolvedValue(status());
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true },
|
||||
|
||||
@@ -19,7 +19,8 @@ import type { StudioUploader } from './studioUpload'
|
||||
* has a handful of completely different kinds of thing you reach for (which
|
||||
* page, what from the journey, what arrangement, what to draw), and putting
|
||||
* them behind tabs in a single 200px column would make each of them cramped.
|
||||
* An icon rail costs 64px and gives the panel the room a photo browser needs.
|
||||
* An icon rail costs 64px, a little more where a section's name holds a longer
|
||||
* word, and gives the panel the room a photo browser needs.
|
||||
*
|
||||
* Which section is open is the shell's when it says so (`section` and
|
||||
* `onSection`), so the inspector on the other side can open the Theme or the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
@@ -9,11 +11,11 @@ import type { StudioUploader } from '../studioUpload';
|
||||
import { RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-007: the rail down the side of Studio
|
||||
* and the panel it opens. Every section is a tab that opens its own panel
|
||||
* under one head, the keyboard reaches every tab, a long name wraps inside
|
||||
* the rail, and the shell can hold the open section so the inspector can open
|
||||
* one from the other side.
|
||||
* the rail and only between words, and the shell can hold the open section so
|
||||
* the inspector can open one from the other side.
|
||||
*/
|
||||
|
||||
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
|
||||
@@ -54,6 +56,16 @@ function draw(over: { section?: RailSection; onSection?: (s: RailSection) => voi
|
||||
);
|
||||
}
|
||||
|
||||
/** Studio's stylesheet, which jsdom does not apply: its rules are the assertion. */
|
||||
const STUDIO_CSS = readFileSync(resolve(process.cwd(), 'src/styles/studio.css'), 'utf8');
|
||||
|
||||
/** The rule written for exactly this selector, searched in `source` (one media query, say). */
|
||||
function rule(selector: string, source = STUDIO_CSS): string {
|
||||
const at = source.indexOf(`${selector} {`);
|
||||
expect(at, `${selector} missing from studio.css`).toBeGreaterThan(-1);
|
||||
return source.slice(at, source.indexOf('}', at) + 1);
|
||||
}
|
||||
|
||||
/** The head of the panel that is open: the name every panel opens on. */
|
||||
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
|
||||
|
||||
@@ -168,4 +180,42 @@ describe('the rail', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
|
||||
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-007: wraps a name only between words, the rail growing to its longest word instead', () => {
|
||||
// "Elements" in the label's 9.5px Poppins is 50.4px wide and a 52px tab left
|
||||
// it 48px, so overflow-wrap: anywhere cut it into "Element" and "s" wherever
|
||||
// no hyphenation dictionary was at hand. Catalan's "Maquetacions" is 77px and
|
||||
// a larger text size widens every word, so no fixed width holds them all.
|
||||
draw();
|
||||
const selector = '.trek-dash .st-rail-btn .st-rail-label';
|
||||
const names = [...document.querySelectorAll('.st-rail-label')];
|
||||
expect(names).toHaveLength(RAIL_SECTIONS.length);
|
||||
// Every name the rail renders is one the rule reaches; .trek-dash is the shell's root.
|
||||
for (const name of names) expect(name.matches(selector.replace('.trek-dash ', ''))).toBe(true);
|
||||
const label = rule(selector);
|
||||
expect(label).toMatch(/overflow-wrap:\s*normal;/);
|
||||
expect(label).toMatch(/word-break:\s*keep-all;/);
|
||||
expect(label).toMatch(/hyphens:\s*manual;/);
|
||||
expect(label).not.toMatch(/anywhere|break-all|break-word|hyphens:\s*auto/);
|
||||
// The type stays on the caption step the user's text size scales.
|
||||
expect(label).toMatch(/font-size:\s*calc\([\d.]+px \* var\(--fs-scale-caption, 1\)\)/);
|
||||
|
||||
// A tab takes the rail's width, and the rail's column is 64px or its
|
||||
// min-content, which is the longest word, in each of the three layouts.
|
||||
expect(rule('.trek-dash .st-rail-btn')).not.toMatch(/[\s;{]width:/);
|
||||
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
|
||||
expect(columns).toEqual([
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr)',
|
||||
'minmax(64px, min-content) minmax(0, 1fr)',
|
||||
]);
|
||||
|
||||
// Below 1080 the panel floats over the workbench's own cell, so it starts
|
||||
// where the rail ends, not at a fixed 64px from the edge.
|
||||
const at = STUDIO_CSS.indexOf('@media (max-width: 1079px) {');
|
||||
expect(at, 'the narrow layout missing from studio.css').toBeGreaterThan(-1);
|
||||
const narrow = STUDIO_CSS.slice(at, STUDIO_CSS.indexOf('\n}', at));
|
||||
expect(narrow).toContain('.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }');
|
||||
expect(rule('.trek-dash .st-side', narrow)).not.toMatch(/position:\s*absolute|inset-inline-start/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
|
||||
* Tab reaches the open section, the arrows move up and down the rail, Home and
|
||||
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
|
||||
* A name longer than the rail is wide wraps onto a second line and breaks
|
||||
* inside a word only when the word alone is wider than the rail; the title
|
||||
* carries it whole either way.
|
||||
* A name longer than the rail is wide wraps onto a second line, only ever
|
||||
* between words: where one word alone is wider, the rail grows to fit it
|
||||
* (studio.css) instead of cutting it. The title carries the name whole.
|
||||
*/
|
||||
export function StudioRail({
|
||||
section,
|
||||
|
||||
@@ -24,9 +24,9 @@ function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDT
|
||||
introKey: 'rel.intro',
|
||||
featuresLabelKey: 'rel.features.label',
|
||||
features: [
|
||||
{ iconName: 'Database', visual: 'places-api', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
|
||||
{ iconName: 'Route', visual: 'roadtrip', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
|
||||
{ iconName: 'MapPin', visual: 'dawarich', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
|
||||
{ iconName: 'Mountain', visual: 'tours', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
|
||||
{ iconName: 'BookOpen', visual: 'studio', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
|
||||
{ iconName: 'Sparkles', visual: 'assistant', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
|
||||
],
|
||||
note: {
|
||||
eyebrowKey: 'rel.note.eyebrow',
|
||||
@@ -78,9 +78,9 @@ describe('ReleaseNoticeModal', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('rel.f1.title')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.rn-feature')).toHaveLength(3)
|
||||
expect(document.querySelector('.rn-vis-places')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-road')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-trail')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-tours')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-studio')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-assist')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => {
|
||||
@@ -194,20 +194,20 @@ describe('ReleaseNoticeModal', () => {
|
||||
const n = releaseNotice()
|
||||
n.release!.features = [
|
||||
...n.release!.features,
|
||||
{ iconName: 'FolderSync', visual: 'docsync', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
|
||||
{ iconName: 'LifeBuoy', visual: 'help', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
|
||||
]
|
||||
renderModal(n)
|
||||
const cards = document.querySelectorAll('.rn-feature')
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(cards[3].classList.contains('rn-feature-wide')).toBe(true)
|
||||
expect([...cards].slice(0, 3).some(c => c.classList.contains('rn-feature-wide'))).toBe(false)
|
||||
// The document sync drawing: the wordmark without its API pill, and the five stores.
|
||||
// The help drawing: the help button and two rows of greetings, each written twice to loop.
|
||||
const wide = cards[3]
|
||||
expect(wide.querySelector('.rn-vis-docs')).not.toBeNull()
|
||||
expect(wide.querySelectorAll('.rn-vis-docs-store')).toHaveLength(5)
|
||||
expect(wide.querySelector('.trek-mark .pill-bg')).toBeNull()
|
||||
// The places card keeps the pill: that one is about the index.
|
||||
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
|
||||
expect(wide.querySelector('.rn-vis-help-q')).not.toBeNull()
|
||||
expect(wide.querySelectorAll('.rn-vis-hello-row')).toHaveLength(2)
|
||||
expect(wide.querySelectorAll('.rn-vis-hello').length % 2).toBe(0)
|
||||
// The tours card lists the kinds of tour along its foot.
|
||||
expect(cards[0].querySelectorAll('.rn-vis-tours-kind')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
|
||||
|
||||
@@ -1,94 +1,166 @@
|
||||
import React from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { ArrowLeftRight, Car, Landmark, Search, Sparkles } from 'lucide-react';
|
||||
import DawarichIcon from '../shared/DawarichIcon';
|
||||
import { NextcloudIcon, OpenCloudIcon, PaperlessIcon, PapraIcon, SynologyDriveIcon } from '../shared/DocumentProviderIcons';
|
||||
import TrekMark from '../shared/TrekMark';
|
||||
import { Bike, Check, Footprints, Landmark, Plane, Sailboat, Snowflake, Sparkles, Star, TrendingUp } from 'lucide-react';
|
||||
import './releaseNoticeVisuals.css';
|
||||
|
||||
/*
|
||||
* The small pictures on the release cards: pieces of TREK itself, drawn at card
|
||||
* size rather than screenshotted, because a screenshot this small is a blur. They
|
||||
* carry no words, only place names and figures that read the same in every
|
||||
* language, so nothing here needs translating.
|
||||
* carry no words, only place names, greetings and figures that read the same in
|
||||
* every language, so nothing here needs translating. Every colour lives in
|
||||
* releaseNoticeVisuals.css, and every motion stops under reduced motion.
|
||||
*/
|
||||
|
||||
function PlacesApiVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-places">
|
||||
<span className="rn-vis-trekbadge"><TrekMark className="rn-vis-trekmark" /></span>
|
||||
<div className="rn-vis-search">
|
||||
<Search size={11} strokeWidth={2.4} />
|
||||
<span className="rn-vis-typed">Fushimi Inari</span>
|
||||
<span className="rn-vis-caret" />
|
||||
</div>
|
||||
<div className="rn-vis-result">
|
||||
<span className="rn-vis-cat" style={{ background: '#ef4444' }}><Landmark size={10} strokeWidth={2.2} /></span>
|
||||
<span className="rn-vis-place">
|
||||
<span>Fushimi Inari Taisha</span>
|
||||
<small>Kyoto</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ── Tours: a trail climbing to the summit at dusk, and the kinds of tour under it ──
|
||||
|
||||
// The route and its stops, in the planner's own route blue and marker style.
|
||||
const ROUTE = 'M26 84 C 58 82, 60 44, 96 46 S 136 74, 160 52 S 176 30, 182 28';
|
||||
const FAR_RIDGE = 'M0 66 L20 50 L38 60 L60 38 L84 56 L104 44 L128 58 L152 40 L176 54 L200 46 V112 H0 Z';
|
||||
const MOUNTAIN = 'M0 112 V86 L44 62 L84 28 L98 38 L112 32 L152 68 L200 80 V112 Z';
|
||||
const SNOW = 'M84 28 L73 38 L79 37 L84 42 L89 37.5 L95 37 Z';
|
||||
const MEADOW = 'M0 112 V96 C 30 88, 60 90, 92 94 S 160 82, 200 90 V112 Z';
|
||||
// From the valley up the mountain's flank to the summit, in switchbacks.
|
||||
const TRAIL = 'M24 100 C 40 96, 50 90, 54 84 S 66 78, 70 72 S 64 62, 72 56 S 82 48, 78 42 S 82 34, 84 29';
|
||||
const KINDS = [Footprints, Bike, Sailboat, Snowflake, Landmark];
|
||||
|
||||
function RoadtripVisual() {
|
||||
function ToursVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-road">
|
||||
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<path className="rn-vis-road-casing" d={ROUTE} />
|
||||
<path className="rn-vis-road-line" d={ROUTE} />
|
||||
<div className="rn-vis rn-vis-tours">
|
||||
<svg className="rn-vis-tours-scene" viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<circle className="rn-vis-tours-sun" cx="160" cy="24" r="9" />
|
||||
<path className="rn-vis-tours-far" d={FAR_RIDGE} />
|
||||
<path className="rn-vis-tours-mountain" d={MOUNTAIN} />
|
||||
<path className="rn-vis-tours-snow" d={SNOW} />
|
||||
<path className="rn-vis-tours-meadow" d={MEADOW} />
|
||||
<path className="rn-vis-tours-trail" d={TRAIL} pathLength={1} />
|
||||
<circle className="rn-vis-tours-walker" r="2.6">
|
||||
<animateMotion dur="5s" begin="1.6s" repeatCount="indefinite" path={TRAIL} />
|
||||
</circle>
|
||||
<g className="rn-vis-tours-flag">
|
||||
<line x1="84" y1="29" x2="84" y2="17" />
|
||||
<path d="M84 17 L93 20 L84 23 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="rn-vis-marker" style={{ left: '13%', top: '75%', background: '#6366f1' }}>1</span>
|
||||
<span className="rn-vis-marker" style={{ left: '48%', top: '41%', background: '#f59e0b' }}>2</span>
|
||||
<span className="rn-vis-marker" style={{ left: '91%', top: '25%', background: '#ec4899' }}>3</span>
|
||||
<span className="rn-vis-distance"><Car size={10} strokeWidth={2.2} />386 km</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A recorded day, with the two places the traveller stayed.
|
||||
const TRAIL = '84,94 96,88 106,80 118,76 130,74 142,66 152,58 162,62 172,66 180,56 186,46';
|
||||
const STAYS: Array<[number, number]> = [[130, 74], [186, 46]];
|
||||
|
||||
function DawarichVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-trail">
|
||||
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<polyline className="rn-vis-trail-line" points={TRAIL} />
|
||||
{STAYS.map(([x, y]) => <circle key={x} className="rn-vis-trail-stay" cx={x} cy={y} r="3.4" />)}
|
||||
</svg>
|
||||
<span className="rn-vis-brand"><DawarichIcon size={58} /></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A trip's documents travelling both ways between TREK and the five stores. The
|
||||
// wordmark without the API pill: this is TREK itself, not its index.
|
||||
const STORES = [PaperlessIcon, PapraIcon, NextcloudIcon, OpenCloudIcon, SynologyDriveIcon];
|
||||
|
||||
function DocSyncVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-docs">
|
||||
<span className="rn-vis-trekbadge"><TrekMark pill={false} className="rn-vis-trekmark" /></span>
|
||||
<span className="rn-vis-docs-flow"><ArrowLeftRight size={14} strokeWidth={2.4} /></span>
|
||||
<div className="rn-vis-docs-stores">
|
||||
{STORES.map((Store, i) => (
|
||||
<span key={i} className="rn-vis-docs-store"><Store size={22} /></span>
|
||||
<span className="rn-vis-tours-climb">
|
||||
<TrendingUp size={9} strokeWidth={2.6} />
|
||||
860 m
|
||||
</span>
|
||||
<div className="rn-vis-tours-kinds">
|
||||
{KINDS.map((Kind, i) => (
|
||||
<span key={i} className="rn-vis-tours-kind" style={{ animationDelay: `${1.2 + i * 0.08}s` }}>
|
||||
<Kind size={10} strokeWidth={2.2} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TREK Studio: an open photo book with a polaroid, a stamp and a sticker ──
|
||||
|
||||
function StudioVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-studio">
|
||||
<div className="rn-vis-book">
|
||||
<div className="rn-vis-page rn-vis-page-start">
|
||||
<div className="rn-vis-polaroid">
|
||||
<svg viewBox="0 0 60 44" aria-hidden="true">
|
||||
<rect className="rn-vis-sky" width="60" height="44" />
|
||||
<circle className="rn-vis-sun" cx="42" cy="15" r="6" />
|
||||
<path className="rn-vis-peak-far" d="M0 32 L12 20 L20 26 L32 12 L44 24 L52 18 L60 26 V44 H0 Z" />
|
||||
<path className="rn-vis-peak-near" d="M0 38 L14 28 L26 34 L38 26 L60 36 V44 H0 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="rn-vis-tape" />
|
||||
</div>
|
||||
<div className="rn-vis-page rn-vis-page-end">
|
||||
<span className="rn-vis-book-title">Dolomiti</span>
|
||||
<span className="rn-vis-book-line" />
|
||||
<span className="rn-vis-book-line rn-vis-book-line-short" />
|
||||
<span className="rn-vis-stamp">
|
||||
<Plane size={10} strokeWidth={2.2} />
|
||||
</span>
|
||||
</div>
|
||||
<span className="rn-vis-gutter" />
|
||||
</div>
|
||||
<span className="rn-vis-sticker">
|
||||
<Star size={15} strokeWidth={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The assistant: a day in Porto proposed stop by stop, ready to apply ──
|
||||
|
||||
const PORTO: Array<{ time: string; name: string; tone: string }> = [
|
||||
{ time: '09:30', name: 'Ribeira', tone: 'sight' },
|
||||
{ time: '12:30', name: 'Livraria Lello', tone: 'culture' },
|
||||
{ time: '16:00', name: 'Torre dos Clérigos', tone: 'view' },
|
||||
];
|
||||
|
||||
function AssistantVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-assist">
|
||||
<div className="rn-vis-assist-head">
|
||||
<span className="rn-vis-assist-avatar">
|
||||
<Sparkles size={10} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="rn-vis-assist-typing">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
<div className="rn-vis-assist-card">
|
||||
{PORTO.map((stop, i) => (
|
||||
<div key={stop.name} className="rn-vis-assist-row" style={{ animationDelay: `${0.5 + i * 0.35}s` }}>
|
||||
<span className="rn-vis-assist-time">{stop.time}</span>
|
||||
<span className={`rn-vis-assist-dot rn-vis-assist-dot-${stop.tone}`} />
|
||||
<span className="rn-vis-assist-name">{stop.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="rn-vis-assist-apply">
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Help on every screen, in 27 languages: hello in each of them, drifting by ──
|
||||
|
||||
const HELLO_A = ['Hallo', 'Bonjour', 'Hola', 'Ciao', 'Olá', 'Hej', 'Ahoj', 'Cześć', 'Szia', 'Tere', 'Salam', 'Bon dia', 'Halo'];
|
||||
const HELLO_B = ['Привіт', 'Привет', 'Γεια σου', 'مرحبا', 'こんにちは', '안녕하세요', '你好', '哈囉', 'สวัสดี', 'Xin chào', 'Merhaba', 'Dobrý deň'];
|
||||
|
||||
function HelloRow({ words, reverse }: { words: string[]; reverse?: boolean }) {
|
||||
// Written twice, so the row can slide by half its width and start over without a seam.
|
||||
return (
|
||||
<div className={reverse ? 'rn-vis-hello-row rn-vis-hello-row-reverse' : 'rn-vis-hello-row'}>
|
||||
{[...words, ...words].map((word, i) => (
|
||||
<span key={i} className="rn-vis-hello" lang="und">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-help">
|
||||
<span className="rn-vis-help-q">?</span>
|
||||
<div className="rn-vis-help-langs">
|
||||
<HelloRow words={HELLO_A} />
|
||||
<HelloRow words={HELLO_B} reverse />
|
||||
</div>
|
||||
<span className="rn-vis-help-count">27</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VISUALS: Record<string, () => React.ReactElement> = {
|
||||
'places-api': PlacesApiVisual,
|
||||
roadtrip: RoadtripVisual,
|
||||
dawarich: DawarichVisual,
|
||||
docsync: DocSyncVisual,
|
||||
tours: ToursVisual,
|
||||
studio: StudioVisual,
|
||||
assistant: AssistantVisual,
|
||||
help: HelpVisual,
|
||||
};
|
||||
|
||||
/** The card's picture, or its icon when the release names no drawing this client knows. */
|
||||
|
||||
@@ -380,223 +380,6 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── The card pictures ─────────────────────────────────────────────────── */
|
||||
|
||||
.rn-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--font-system);
|
||||
}
|
||||
|
||||
.rn-vis-road > svg,
|
||||
.rn-vis-trail > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
|
||||
.rn-vis-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0 / 0.55);
|
||||
}
|
||||
|
||||
/* Places API: the index answering a search while it is typed. */
|
||||
.rn-vis-places {
|
||||
padding: 9px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* The same wordmark the API keys card leads with, centred; its pill text inverts against the tile. */
|
||||
.rn-vis-trekbadge { align-self: center; display: flex; align-items: center; height: 24px; }
|
||||
|
||||
.rn-vis-trekmark { display: block; height: 20px; width: auto; color: #f5f5f7; }
|
||||
|
||||
.rn-vis-places .trek-mark .pill-fg { fill: #101013; }
|
||||
|
||||
.rn-vis-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
background: oklch(1 0 0 / 0.08);
|
||||
border: 1px solid oklch(1 0 0 / 0.16);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rn-vis-search svg { width: 11px; height: 11px; flex: none; color: oklch(1 0 0 / 0.55); }
|
||||
|
||||
/* The query types itself out once, then the answer arrives under it. */
|
||||
.rn-vis-typed {
|
||||
display: inline-block;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
animation: rn-type 1.3s steps(13, end) 0.7s both;
|
||||
}
|
||||
|
||||
@keyframes rn-type { from { max-width: 0; } to { max-width: 90px; } }
|
||||
|
||||
.rn-vis-caret {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
margin-inline-start: -4px;
|
||||
background: #f5f5f7;
|
||||
animation: rn-caret 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rn-caret { 50% { opacity: 0; } }
|
||||
|
||||
.rn-vis-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: oklch(1 0 0 / 0.07);
|
||||
animation: rn-answer 0.3s ease 2.1s both;
|
||||
}
|
||||
|
||||
@keyframes rn-answer { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.rn-vis-cat {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid #ffffff;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rn-vis-place { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
|
||||
|
||||
.rn-vis-place span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rn-vis-place small { font-size: 8.5px; color: oklch(1 0 0 / 0.5); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-vis-typed, .rn-vis-caret, .rn-vis-result { animation: none; }
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-typed,
|
||||
[data-reduce-motion='true'] .rn-vis-caret,
|
||||
[data-reduce-motion='true'] .rn-vis-result { animation: none; }
|
||||
|
||||
/* Road trip: the drive on the map, in the planner's route blue and marker style. */
|
||||
.rn-vis-road-casing { fill: none; stroke: #0a5cc2; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-road-line { fill: none; stroke: #0a84ff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-marker {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -9px 0 0 -9px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 4px oklch(0 0 0 / 0.35);
|
||||
color: #ffffff;
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rn-vis-distance {
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Dawarich: a recorded day, in the trail colour the map draws it with. */
|
||||
.rn-vis-trail-line { fill: none; stroke: #2563eb; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-trail-stay { fill: #ffffff; stroke: #2563eb; stroke-width: 2; }
|
||||
|
||||
/* Document sync: the wordmark, the two-way arrow under it, and the five stores in a row. */
|
||||
.rn-vis-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.rn-vis-docs .rn-vis-trekbadge { height: 22px; }
|
||||
.rn-vis-docs .rn-vis-trekmark { height: 19px; }
|
||||
|
||||
.rn-vis-docs-flow { display: flex; color: oklch(1 0 0 / 0.55); transform: rotate(90deg); }
|
||||
|
||||
.rn-vis-docs-stores {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rn-vis-docs-store {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.18), inset 0 0 0 1px oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* The mark itself, unframed, with its corners rounded like an app icon. */
|
||||
.rn-vis-brand {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px oklch(0 0 0 / 0.45), 0 0 0 1px oklch(1 0 0 / 0.14);
|
||||
}
|
||||
|
||||
/* A glass sheen over the mark: a soft light from the top and a bright inner edge. */
|
||||
.rn-vis-brand::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(160deg, oklch(1 0 0 / 0.42) 0%, oklch(1 0 0 / 0.08) 38%, transparent 55%, oklch(0 0 0 / 0.12) 100%);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.55), inset 0 0 0 1px oklch(1 0 0 / 0.22);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rn-vis-brand img { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* ── Right: the note ───────────────────────────────────────────────────── */
|
||||
|
||||
.rn-note {
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
/*
|
||||
* The pictures on the release cards (ReleaseNoticeVisuals.tsx). They sit on the
|
||||
* release half, which is dark in both themes, so they draw in their own colours
|
||||
* rather than the surface tokens, like the panel around them.
|
||||
*/
|
||||
|
||||
/* ── The card pictures ─────────────────────────────────────────────────── */
|
||||
|
||||
.rn-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--font-system);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rn-vis-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0 / 0.55);
|
||||
}
|
||||
|
||||
@keyframes rn-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
@keyframes rn-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes rn-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes rn-typing {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: none;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
@keyframes rn-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 oklch(0.72 0.19 150 / 0.55);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 9px oklch(0.72 0.19 150 / 0);
|
||||
}
|
||||
}
|
||||
@keyframes rn-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@keyframes rn-sticker {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.2) rotate(-50deg);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: scale(1.12) rotate(12deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tours: a trail climbing to the summit at dusk, and the kinds of tour along the foot. */
|
||||
.rn-vis-tours {
|
||||
background: linear-gradient(180deg, #24304f 0%, #3c4a6b 46%, #c9876a 100%);
|
||||
}
|
||||
|
||||
.rn-vis-tours-scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rn-vis-tours-sun {
|
||||
fill: #fde68a;
|
||||
filter: drop-shadow(0 0 6px oklch(0.92 0.12 90 / 0.7));
|
||||
}
|
||||
|
||||
.rn-vis-tours-far {
|
||||
fill: #56668a;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.rn-vis-tours-mountain {
|
||||
fill: #23344a;
|
||||
}
|
||||
|
||||
.rn-vis-tours-snow {
|
||||
fill: #e8f0f8;
|
||||
}
|
||||
|
||||
.rn-vis-tours-meadow {
|
||||
fill: #14202d;
|
||||
}
|
||||
|
||||
.rn-vis-tours-trail {
|
||||
fill: none;
|
||||
stroke: #fbbf24;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
filter: drop-shadow(0 0 2px oklch(0.85 0.16 80 / 0.6));
|
||||
animation: rn-draw 1.3s ease-in-out 0.3s both;
|
||||
}
|
||||
|
||||
.rn-vis-tours-walker {
|
||||
fill: #ffffff;
|
||||
filter: drop-shadow(0 0 3px #ffffff);
|
||||
opacity: 0;
|
||||
animation: rn-show 0.2s linear 1.6s both;
|
||||
}
|
||||
|
||||
@keyframes rn-show {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 0% 100%;
|
||||
animation: rn-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 1.5s both;
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag line {
|
||||
stroke: #f5f5f7;
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag path {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.rn-vis-tours-climb {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.14);
|
||||
border: 1px solid oklch(1 0 0 / 0.18);
|
||||
backdrop-filter: blur(6px);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rn-vis-tours-kinds {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 7px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rn-vis-tours-kind {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: oklch(1 0 0 / 0.12);
|
||||
border: 1px solid oklch(1 0 0 / 0.2);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: rn-rise 0.3s ease both;
|
||||
}
|
||||
|
||||
/* TREK Studio: an open book, a polaroid taped in, a title, a stamp and a sticker. */
|
||||
.rn-vis-studio {
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% 115%, oklch(0.62 0.14 45 / 0.38) 0%, transparent 60%),
|
||||
linear-gradient(165deg, #23201c 0%, #171513 100%);
|
||||
}
|
||||
|
||||
.rn-vis-book {
|
||||
position: absolute;
|
||||
inset: 13px 20px 13px;
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
transform: perspective(520px) rotateX(16deg);
|
||||
box-shadow:
|
||||
0 16px 22px -10px oklch(0 0 0 / 0.75),
|
||||
0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
.rn-vis-page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rn-vis-page-start {
|
||||
background: #ede2c9;
|
||||
}
|
||||
|
||||
.rn-vis-page-end {
|
||||
background: #f6eedc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 11px 9px 0;
|
||||
}
|
||||
|
||||
.rn-vis-gutter {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 50%;
|
||||
width: 16px;
|
||||
margin-inline-start: -8px;
|
||||
background: linear-gradient(90deg, transparent, oklch(0 0 0 / 0.2), transparent);
|
||||
}
|
||||
|
||||
.rn-vis-polaroid {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
inset-inline-start: 10px;
|
||||
width: 58%;
|
||||
padding: 3px 3px 9px;
|
||||
background: #ffffff;
|
||||
transform: rotate(-6deg);
|
||||
box-shadow: 0 3px 6px oklch(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.rn-vis-polaroid svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.rn-vis-sky {
|
||||
fill: #fcd5b0;
|
||||
}
|
||||
.rn-vis-sun {
|
||||
fill: #fb923c;
|
||||
}
|
||||
.rn-vis-peak-far {
|
||||
fill: #7c8aa0;
|
||||
}
|
||||
.rn-vis-peak-near {
|
||||
fill: #334155;
|
||||
}
|
||||
|
||||
.rn-vis-tape {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
inset-inline-start: 30%;
|
||||
width: 24px;
|
||||
height: 7px;
|
||||
transform: rotate(-24deg);
|
||||
background: repeating-linear-gradient(45deg, #f43f5e 0 3px, #fecdd3 3px 6px);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.rn-vis-book-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #1f3d2b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rn-vis-book-line {
|
||||
height: 3px;
|
||||
width: 88%;
|
||||
border-radius: 2px;
|
||||
background: oklch(0.4 0.03 60 / 0.3);
|
||||
}
|
||||
.rn-vis-book-line-short {
|
||||
width: 58%;
|
||||
}
|
||||
|
||||
.rn-vis-stamp {
|
||||
position: absolute;
|
||||
bottom: 9px;
|
||||
inset-inline-end: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px dashed #b45309;
|
||||
color: #b45309;
|
||||
transform: rotate(-14deg);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rn-vis-sticker {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
inset-inline-start: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #facc15;
|
||||
color: #fff8db;
|
||||
box-shadow:
|
||||
0 0 0 2px #ffffff,
|
||||
0 4px 10px oklch(0 0 0 / 0.4);
|
||||
animation: rn-sticker 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1s both;
|
||||
}
|
||||
|
||||
.rn-vis-sticker svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* The assistant: a day in Porto proposed stop by stop, and the button that applies it. */
|
||||
.rn-vis-assist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
background:
|
||||
radial-gradient(110% 80% at 0% 0%, oklch(0.55 0.2 290 / 0.36) 0%, transparent 62%),
|
||||
linear-gradient(165deg, #1d1b29 0%, #141320 100%);
|
||||
}
|
||||
|
||||
.rn-vis-assist-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rn-vis-assist-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #3b82f6 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px oklch(0.55 0.2 290 / 0.6);
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f7;
|
||||
animation: rn-typing 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing i:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.rn-vis-assist-typing i:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.rn-vis-assist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 9px;
|
||||
background: oklch(1 0 0 / 0.08);
|
||||
border: 1px solid oklch(1 0 0 / 0.14);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.rn-vis-assist-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.35;
|
||||
animation: rn-rise 0.34s ease both;
|
||||
}
|
||||
|
||||
.rn-vis-assist-time {
|
||||
flex: none;
|
||||
width: 27px;
|
||||
color: oklch(1 0 0 / 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rn-vis-assist-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.rn-vis-assist-dot-sight {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.rn-vis-assist-dot-culture {
|
||||
background: #a78bfa;
|
||||
}
|
||||
.rn-vis-assist-dot-view {
|
||||
background: #34d399;
|
||||
}
|
||||
.rn-vis-assist-name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rn-vis-assist-apply {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-end: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
color: #ffffff;
|
||||
animation:
|
||||
rn-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) 1.7s both,
|
||||
rn-ring 2.2s ease-out 2s infinite;
|
||||
}
|
||||
|
||||
/* Help on every screen: the help button, and hello in TREK's languages drifting past it. */
|
||||
.rn-vis-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
background:
|
||||
radial-gradient(70% 120% at 10% 50%, oklch(0.6 0.16 250 / 0.34) 0%, transparent 70%),
|
||||
linear-gradient(165deg, #1a1f2b 0%, #12151d 100%);
|
||||
}
|
||||
|
||||
.rn-vis-help-q {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
0 0 0 5px oklch(1 0 0 / 0.08),
|
||||
0 6px 16px oklch(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.rn-vis-help-count {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
inset-inline-start: 42px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 99px;
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 0 0 2px #12151d;
|
||||
}
|
||||
|
||||
/* The rows slide the same way in every language: they are a picture, not text. */
|
||||
.rn-vis-help-langs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
direction: ltr;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
|
||||
}
|
||||
|
||||
.rn-vis-hello-row {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: rn-marquee 34s linear infinite;
|
||||
}
|
||||
.rn-vis-hello-row-reverse {
|
||||
animation-direction: reverse;
|
||||
animation-duration: 40s;
|
||||
}
|
||||
|
||||
.rn-vis-hello {
|
||||
flex: none;
|
||||
margin-inline-end: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.09);
|
||||
border: 1px solid oklch(1 0 0 / 0.13);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-vis-tours-trail,
|
||||
.rn-vis-tours-flag,
|
||||
.rn-vis-tours-kind,
|
||||
.rn-vis-sticker,
|
||||
.rn-vis-assist-typing i,
|
||||
.rn-vis-assist-row,
|
||||
.rn-vis-assist-apply,
|
||||
.rn-vis-hello-row {
|
||||
animation: none;
|
||||
}
|
||||
.rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.rn-vis-tours-walker {
|
||||
display: none;
|
||||
}
|
||||
.rn-vis-sticker {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-trail,
|
||||
[data-reduce-motion='true'] .rn-vis-tours-flag,
|
||||
[data-reduce-motion='true'] .rn-vis-tours-kind,
|
||||
[data-reduce-motion='true'] .rn-vis-sticker,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-typing i,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-row,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-apply,
|
||||
[data-reduce-motion='true'] .rn-vis-hello-row {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-walker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-sticker {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DOCUMENT_PROVIDER_IDS } from '@trek/shared'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from './DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from './documentProviderIconMap'
|
||||
import TrekIcon from './TrekIcon'
|
||||
|
||||
const marks = Object.entries(DOCUMENT_PROVIDER_ICONS)
|
||||
|
||||
@@ -15,7 +15,7 @@ import React from 'react'
|
||||
* for legibility in a monochrome row.
|
||||
*/
|
||||
|
||||
interface GlyphProps {
|
||||
export interface GlyphProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
@@ -126,12 +126,3 @@ export function SynologyDriveIcon({ size = 20, className }: GlyphProps): React.R
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Keyed by the provider ids in `document_providers`. */
|
||||
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
|
||||
paperless: PaperlessIcon,
|
||||
papra: PapraIcon,
|
||||
nextcloud: NextcloudIcon,
|
||||
opencloud: OpenCloudIcon,
|
||||
synologydrive: SynologyDriveIcon,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
type GlyphProps,
|
||||
NextcloudIcon,
|
||||
OpenCloudIcon,
|
||||
PaperlessIcon,
|
||||
PapraIcon,
|
||||
SynologyDriveIcon,
|
||||
} from './DocumentProviderIcons';
|
||||
|
||||
/**
|
||||
* The document providers' marks keyed by the provider ids in `document_providers`.
|
||||
* Kept apart from the drawings so that module exports components only.
|
||||
*/
|
||||
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
|
||||
paperless: PaperlessIcon,
|
||||
papra: PapraIcon,
|
||||
nextcloud: NextcloudIcon,
|
||||
opencloud: OpenCloudIcon,
|
||||
synologydrive: SynologyDriveIcon,
|
||||
};
|
||||
@@ -70,6 +70,19 @@ interface CachedTourDraft {
|
||||
draft: TourDraft;
|
||||
}
|
||||
|
||||
/**
|
||||
* A road route as the router answered it, kept on the device so a trip opens
|
||||
* with its roads drawn instead of asking the router for every leg again (#2794).
|
||||
* `key` is RouteCalculator's cache key (profile, unit, waypoints, avoided
|
||||
* roads), `route` its answer as it came, `savedAt` when it came: an old answer
|
||||
* is asked again, and the oldest give way first.
|
||||
*/
|
||||
interface CachedRoute {
|
||||
key: string;
|
||||
route: unknown;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
// ── Queue + sync types ────────────────────────────────────────────────────────
|
||||
|
||||
// 'conflict' is terminal-until-resolved: the server rejected the replay because
|
||||
@@ -279,6 +292,7 @@ export class TrekOfflineDb extends Dexie {
|
||||
ferrataTiles!: Table<CachedFerrataTile, [number, string]>;
|
||||
tourStationVisits!: Table<CachedTourStationVisit, [number, string]>;
|
||||
tourDrafts!: Table<CachedTourDraft, number>;
|
||||
routeCache!: Table<CachedRoute, string>;
|
||||
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
@@ -366,6 +380,11 @@ export class TrekOfflineDb extends Dexie {
|
||||
// v13: the Tours planner's unsaved draft as the account last said it, one
|
||||
// per trip (the database is the signed-in user's own), so it opens offline.
|
||||
this.version(13).stores({ tourDrafts: 'tripId' });
|
||||
|
||||
// v14: road routes the router answered, by their cache key, so a trip with its
|
||||
// whole route shown opens drawn instead of re-routing every leg (#2794). Not per
|
||||
// trip: the same waypoints are the same road in any trip; `savedAt` to prune.
|
||||
this.version(14).stores({ routeCache: 'key, savedAt' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/documentProviderIconMap'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import {
|
||||
type Addon,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome';
|
||||
import { useAssistantStatus } from '../../../components/Assistant/useAssistantStatus';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
|
||||
|
||||
@@ -26,7 +27,6 @@ export default function MAssistantHomeScreen() {
|
||||
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 close = useAssistantStore((s) => s.close);
|
||||
|
||||
// The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there.
|
||||
@@ -46,19 +46,21 @@ export default function MAssistantHomeScreen() {
|
||||
[close, navigate]
|
||||
);
|
||||
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask here.
|
||||
useAssistantStatus();
|
||||
|
||||
if (inTrip || !available || !open || minimized) return null;
|
||||
// Mounted beside MobileShell, not inside it, so the --m-* tokens the screen paints
|
||||
// with only resolve under an m-root of its own; without it the chat is see-through.
|
||||
return (
|
||||
<AssistantScreen
|
||||
tripId={chat.id}
|
||||
tripTitle={chat.title}
|
||||
hidden={false}
|
||||
hops={hops}
|
||||
onOpenTripChat={journal ? undefined : home.openTripChat}
|
||||
/>
|
||||
<div className="m-root">
|
||||
<AssistantScreen
|
||||
tripId={chat.id}
|
||||
tripTitle={chat.title}
|
||||
hidden={false}
|
||||
hops={hops}
|
||||
onOpenTripChat={journal ? undefined : home.openTripChat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useInAppNotificationStore } from '../../../store/inAppNotificationStore
|
||||
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
|
||||
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
|
||||
import type { TripCardBadge } from '../../../api/client'
|
||||
import DemoBanner from '../../../components/Layout/DemoBanner'
|
||||
import DemoWelcome from '../../../components/Demo/DemoWelcome'
|
||||
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
|
||||
import PluginWidgets from '../../../components/Plugins/PluginWidgets'
|
||||
import { entityGradient } from '../../../utils/gradients'
|
||||
@@ -247,7 +247,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
|
||||
<MUserMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||
|
||||
{demoMode && <DemoBanner />}
|
||||
{demoMode && <DemoWelcome />}
|
||||
|
||||
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
|
||||
{loadError && (
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
@@ -44,6 +48,8 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'gemini-flash', label: 'Gemini Flash', supportsTools: true, free: true },
|
||||
{ id: 'gemini-pro', label: 'Gemini Pro', supportsTools: true },
|
||||
@@ -83,4 +89,20 @@ describe('MAiConnectionSection', () => {
|
||||
expect(await screen.findByText('What would you like to chat with?')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Paste an API key/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AI-DEMO-001: the shared demo account sees the place for its own connection locked', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<MAiConnectionSection />);
|
||||
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
|
||||
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AI-DEMO-002: any other account keeps the admin hint', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<MAiConnectionSection />);
|
||||
expect(await screen.findByText('Your admin sets this up.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { AiConnectionView } from '@trek/shared';
|
||||
import { ChevronDown, Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../../components/Assistant/useAssistantMapFollow';
|
||||
import DemoLock from '../../../components/Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock';
|
||||
import AiConnectPanel from '../../../components/Settings/ai/AiConnectPanel';
|
||||
import { aiSectionText, CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
|
||||
import { useAiConnections } from '../../../components/Settings/ai/useAiConnections';
|
||||
@@ -117,6 +119,30 @@ function MInstanceConnection({ S }: { S: AiConnections }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Under the connection: the hint that only the admin connects one, or, for the
|
||||
* shared demo account, the locked place where its own would go.
|
||||
*/
|
||||
function MOwnConnectionNote({ view }: { view: AiConnections['view'] }) {
|
||||
const { t } = useTranslation();
|
||||
const demoLocked = useDemoAccountLock();
|
||||
if (view === 'loading') return null;
|
||||
if (!demoLocked) return view === 'adminOnly' ? <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint> : null;
|
||||
return (
|
||||
<DemoLock locked reason={t('demo.lock.ai')} size="row" className="mt-3">
|
||||
<MSetRow
|
||||
first
|
||||
label={t('demo.lock.aiTitle')}
|
||||
trailing={
|
||||
<MSetButton variant="ghost" disabled>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</MSetButton>
|
||||
}
|
||||
/>
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the trip map follows the assistant, the phone's row for the shared switch. */
|
||||
function MAssistantBehaviour() {
|
||||
const { t } = useTranslation();
|
||||
@@ -180,7 +206,7 @@ export default function MAiConnectionSection({
|
||||
{S.view === 'own' && S.own && <MOwnConnection S={S} own={S.own} />}
|
||||
{S.view === 'instance' && <MInstanceConnection S={S} />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <MAssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint>}
|
||||
<MOwnConnectionNote view={S.view} />
|
||||
|
||||
{S.panel && (
|
||||
<div
|
||||
|
||||
@@ -22,6 +22,7 @@ import MConfirmSheet from './MConfirmSheet'
|
||||
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
|
||||
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
|
||||
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
|
||||
import AccountDemoLock from '../../../components/Settings/AccountDemoLock'
|
||||
|
||||
/**
|
||||
* "Account" section — AccountTab parity: profile + avatar, password change,
|
||||
@@ -78,6 +79,8 @@ export default function MSettingsAccount() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The shared demo account: everything here is shown, nothing can be changed. */}
|
||||
<AccountDemoLock>
|
||||
{/* ── Profile ─────────────────────────────────────────────── */}
|
||||
<MSetCard title={t('settings.account')} icon={User}>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
@@ -185,10 +188,7 @@ export default function MSettingsAccount() {
|
||||
)}
|
||||
<p className="text-[0.75rem] leading-relaxed text-m-muted">{t('settings.mfa.description')}</p>
|
||||
|
||||
{demoMode ? (
|
||||
<p className="mt-2 text-[0.75rem] font-semibold text-[color:var(--m-st-pending)]">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<p className="mt-2 text-[0.78125rem] font-bold text-m-ink">
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
@@ -287,11 +287,11 @@ export default function MSettingsAccount() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</MSetCard>
|
||||
|
||||
<MPasskeysCard demoMode={demoMode} />
|
||||
</AccountDemoLock>
|
||||
|
||||
<MConfirmSheet
|
||||
open={showDeleteConfirm === 'blocked'}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user