Compare commits

..

4 Commits

Author SHA1 Message Date
Maurice 2c065356dc test(studio): drive the travel inspector's type through the typeface list and the weight slider 2026-10-23 20:40:40 +02:00
Maurice e0cca62347 fix(studio): declare Big Shoulders Display's bare style imports, and let a raced Studio save carry its revision 2026-10-23 20:10:31 +02:00
Maurice 1222fb3901 fix(studio): import Big Shoulders Display by its export name, which takes no .css 2026-10-23 20:10:30 +02:00
Maurice 142aa61295 feat(studio): 34 typefaces with real weights and italics, a categorised font picker
The book catalogue grows from 7 families to 34, all self-hosted through
@fontsource under the SIL OFL 1.1 or the Apache License 2.0: condensed and
poster faces, heavy retro display, elegant display serifs, Montserrat, twelve
scripts and DM Mono. Weights run from 100 to 900, each family offering only
the weights it ships, and the serifs gain real italics.

The catalogue lives in @trek/shared (book-fonts.data.ts) with each family's
roles (display, heading, body, script, mono), weights, italics and letter
width, so the Studio, the findings, the design engine and the assistant read
one list. Page operations and the design engine set a weight a family lacks
at its nearest one, describe_book_page names the role and the weight, and
font_mix no longer counts scripts and mono faces, warning above three.

The Studio gets StudioFontPicker: the current family set in itself, a
searchable list grouped by kind with every family in its own face and the
book's own families first, and a weight slider over the weights the family
has. Faces load only when used; the assistant's page snapshot embeds only the
faces a page draws, the service worker installs only the latin files of the
original seven families, and the faces of an opened book are cached for
offline use. BOOK_SCHEMA_REV goes to 2.
2026-10-23 20:10:30 +02:00
945 changed files with 3244 additions and 67728 deletions
-3
View File
@@ -112,9 +112,6 @@ data:
{{- if .Values.env.TREK_PLACES_URL }} {{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }} TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }} {{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }} {{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }} AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }} {{- end }}
-1
View File
@@ -108,7 +108,6 @@ env:
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on. # here. Defaults to on.
# TREK_PLACES_URL: "" # TREK_PLACES_URL: ""
# GOOGLE_LIST_DATA_BASE: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK. # configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com. # Defaults to https://places.liketrek.com.
+1 -1
View File
@@ -108,7 +108,7 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25", "tz-lookup": "^6.1.25",
"zod": "^4.6.7", "zod": "^4.6.5",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
+8 -8
View File
@@ -1,18 +1,18 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 401, "@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 850, "@typescript-eslint/no-misused-promises": 854,
"no-console": 88, "no-console": 88,
"complexity": 226, "complexity": 226,
"@typescript-eslint/no-explicit-any": 146, "@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130, "trek/no-swallowed-catch": 131,
"max-params": 33, "max-params": 33,
"trek/store-without-selector": 67, "trek/store-without-selector": 67,
"react-hooks/refs": 556, "react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 194, "react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165, "@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116, "react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 125, "@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28, "react-hooks/immutability": 28,
"max-lines-per-function": 132, "max-lines-per-function": 132,
"react-hooks/static-components": 78, "react-hooks/static-components": 78,
@@ -20,7 +20,7 @@
"no-useless-assignment": 7, "no-useless-assignment": 7,
"no-empty": 19, "no-empty": 19,
"trek/no-window-globals": 41, "trek/no-window-globals": 41,
"react-refresh/only-export-components": 45, "react-refresh/only-export-components": 46,
"unused-disable-directive": 6, "unused-disable-directive": 6,
"max-depth": 21, "max-depth": 21,
"react-hooks/void-use-memo": 1, "react-hooks/void-use-memo": 1,
@@ -34,7 +34,7 @@
"react-hooks/exhaustive-deps": 46 "react-hooks/exhaustive-deps": 46
}, },
"tests": { "tests": {
"@typescript-eslint/no-explicit-any": 490, "@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1932, "@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-unused-vars": 51,
+1 -9
View File
@@ -69,15 +69,6 @@
"desktop": ["src/components/Assistant/AssistantWindow.tsx"], "desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"] "mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
}, },
{
"feature": "AI assistant home chat",
"hooks": ["src/components/Assistant/useAssistantHome.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": [
"src/mobile/screens/assistant/MAssistantHomeScreen.tsx",
"src/mobile/screens/dashboard/MDashboard.tsx"
]
},
{ {
"feature": "Assistant hints", "feature": "Assistant hints",
"hooks": ["src/components/Assistant/useAssistantHints.ts"], "hooks": ["src/components/Assistant/useAssistantHints.ts"],
@@ -601,6 +592,7 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"], "hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [ "desktop": [
"src/components/Planner/PlaceInspector.tsx", "src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts" "src/pages/tripPlanner/useTripPlannerPage.ts"
], ],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"] "mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
+2
View File
@@ -1123,6 +1123,8 @@
"src/components/shared/PasswordChecklist.tsx", "src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.tsx", "src/components/shared/PlaceAvatar.test.tsx",
"src/components/shared/PlaceAvatar.tsx", "src/components/shared/PlaceAvatar.tsx",
"src/components/shared/PlaceAvatarUpload.test.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/components/shared/PluginIcon.test.tsx", "src/components/shared/PluginIcon.test.tsx",
"src/components/shared/PluginIcon.tsx", "src/components/shared/PluginIcon.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx", "src/components/shared/PublicLanguagePicker.test.tsx",
-7
View File
@@ -144,13 +144,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en', 'memories.* key in en',
})) }))
), ),
{
file: 'components/Studio/stickerLibrary.ts',
template: 'journey.studio.sticker.${id}',
because:
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
'sticker the library holds against en',
},
]; ];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/; const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -26,7 +26,7 @@
"packing.ts": 6, "packing.ts": 6,
"pdf.ts": 1, "pdf.ts": 1,
"perm.ts": 4, "perm.ts": 4,
"places.ts": 9, "places.ts": 10,
"planner.ts": 53, "planner.ts": 53,
"reservations.ts": 21, "reservations.ts": 21,
"roadtrip.ts": 36, "roadtrip.ts": 36,
+1
View File
@@ -116,6 +116,7 @@
"src/components/Vacay/useVacayCalendarLogic.ts", "src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts", "src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts", "src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts", "src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts", "src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts", "src/hooks/useDawarichSuggestions.ts",
+4 -3
View File
@@ -18,6 +18,7 @@
"components/Journey/JourneyMap.tsx": 1, "components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2, "components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1, "components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1, "components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1, "components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1, "components/Layout/OfflineBanner.tsx": 1,
@@ -51,9 +52,8 @@
"components/Studio/BookSheetsView.tsx": 3, "components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1, "components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1, "components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4, "components/Studio/SpreadView.tsx": 8,
"components/Studio/SpreadView.tsx": 4, "components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/StudioElementsPanel.tsx": 1, "components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1, "components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1, "components/Studio/StudioInspector.tsx": 1,
@@ -73,6 +73,7 @@
"components/shared/DetailShell.tsx": 1, "components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2, "components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2, "components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1, "components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3, "components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3, "components/shared/StarRating.tsx": 3,
+2 -4
View File
@@ -1,6 +1,4 @@
{ {
"components/Studio/LineHandles.tsx": 1, "pages/tripPlanner/PlanMapStage.tsx": 4,
"components/Studio/StudioSelectionChrome.tsx": 2, "mobile/components/trek3d/MascotClip.tsx": 1
"mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
} }
+4 -4
View File
@@ -8,21 +8,21 @@
"src/components/Map/MapView.tsx": 1399, "src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2290, "src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076, "src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082, "src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953, "src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/PlaceFormModal.test.tsx": 2836, "src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157, "src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1290, "src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738, "src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/countryShapes.ts": 2595, "src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636, "src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030, "src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348, "src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128, "src/pages/TripPlannerPage.tsx": 1131,
"src/pages/atlas/useAtlas.ts": 1211, "src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
+3 -1
View File
@@ -51,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1, "src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3, "src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1, "src/components/Planner/PlaceFormModal.tsx": 1,
"src/components/Planner/PlaceInspector.tsx": 16, "src/components/Planner/PlaceInspector.tsx": 17,
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1, "src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1, "src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8, "src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -82,6 +82,7 @@
"src/components/Settings/NotificationsTab.test.tsx": 1, "src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1, "src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2, "src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1, "src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1, "src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/TourPlannerPanels.tsx": 2,
@@ -97,6 +98,7 @@
"src/components/shared/CustomSelect.test.tsx": 1, "src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2, "src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1, "src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1, "src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2, "src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
+4 -4
View File
@@ -100,10 +100,9 @@
"components/Roadtrip/serviceMarker.ts": 1, "components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5, "components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2, "components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2, "components/Studio/SpreadView.tsx": 3,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 8, "components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 1, "components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2, "components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2, "components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5, "components/Studio/TravelElements.tsx": 5,
@@ -133,6 +132,7 @@
"components/shared/Modal.tsx": 3, "components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1, "components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2, "components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3, "components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2, "components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2, "components/shared/Spinner.tsx": 2,
@@ -252,7 +252,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3, "mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1, "mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2, "mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23, "mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2, "mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9, "mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12, "mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
+1 -2
View File
@@ -40,8 +40,7 @@
"components/Settings/AppearanceSettingsTab.tsx": 1, "components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1, "components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1, "components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1, "components/Studio/SpreadView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 2, "components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1, "components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6, "components/Studio/TravelElements.tsx": 6,
+3 -4
View File
@@ -67,7 +67,6 @@ const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePag
const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost')) const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost'))
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up. // The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost')) const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
const MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
// The ten phone screens are chunks of their own, alongside the desktop pages // The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and // rather than inside them. Each page used to import its M screen statically and
@@ -411,10 +410,10 @@ export default function App() {
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>} {!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)} {!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>} {!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>}
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */} {/* The phone has its own assistant screen inside the trip. */}
{!hideAuthedWidgets && isAuthenticated && ( {!hideAuthedWidgets && isAuthenticated && !isPhone && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}> <ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense> <Suspense fallback={null}><AssistantHost /></Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary> <ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
-42
View File
@@ -165,46 +165,4 @@ describe('assistantApi JSON calls', () => {
await assistantApi.stop(journey); await assistantApi.stop(journey);
expect(hits).toEqual(['threads', 'undo', 'stop']); expect(hits).toEqual(['threads', 'undo', 'stop']);
}); });
it('FE-AI-API-009: the home scope reaches the routes outside any trip', async () => {
const home = { kind: 'home' } as const;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
const signal = new AbortController().signal;
await assistantApi.chat(home, BODY, signal);
await assistantApi.decide(home, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
'/api/assistant/chat',
'/api/assistant/threads/7/decisions',
]);
const hits: string[] = [];
server.use(
http.get('/api/assistant/threads', () => {
hits.push('threads');
return HttpResponse.json({ threads: [] });
}),
http.get('/api/assistant/threads/3', () => {
hits.push('thread');
return HttpResponse.json({ thread: { id: 3, title: 't', lastMessageAt: 'x' }, messages: [] });
}),
http.delete('/api/assistant/threads/3', () => {
hits.push('delete');
return HttpResponse.json({ success: true });
}),
http.post('/api/assistant/tool-calls/:id/undo', () => {
hits.push('undo');
return HttpResponse.json({ undone: true });
}),
http.post('/api/assistant/stop', () => {
hits.push('stop');
return HttpResponse.json({ success: true });
})
);
await assistantApi.threads(home);
await assistantApi.thread(home, 3);
await assistantApi.deleteThread(home, 3);
await assistantApi.undo(home, 'c1');
await assistantApi.stop(home);
expect(hits).toEqual(['threads', 'thread', 'delete', 'undo', 'stop']);
});
}); });
+3 -13
View File
@@ -18,7 +18,6 @@ import {
type AssistantChatRequest, type AssistantChatRequest,
type AssistantDecisionsRequest, type AssistantDecisionsRequest,
type AssistantErrorCode, type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse, type AssistantRenderUploadResponse,
type AssistantScope, type AssistantScope,
type AssistantShareRequest, type AssistantShareRequest,
@@ -103,16 +102,14 @@ const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
/** /**
* What a conversation call is about: a trip's id (the planner's chat), or a * What a conversation call is about: a trip's id (the planner's chat), or a
* scope, which also reaches a journey's chat in TREK Studio and the home chat * scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
* outside any one trip. Threads, turns, decisions, undo and stop exist under * decisions, undo and stop exist under both; files, found places, proposals
* all three; files, found places, proposals and sharing belong to a trip and * and sharing belong to a trip and take its id.
* take its id.
*/ */
export type AssistantTarget = number | AssistantScope; export type AssistantTarget = number | AssistantScope;
function scopeBase(target: AssistantTarget): string { function scopeBase(target: AssistantTarget): string {
if (typeof target === 'number') return tripBase(target); if (typeof target === 'number') return tripBase(target);
if (target.kind === 'home') return '/assistant';
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`; return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
} }
@@ -146,13 +143,6 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> => ): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data), apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
insertFoundPlace: (
tripId: number,
threadId: number,
body: AssistantInsertFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it. // A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: ( takeProposal: (
tripId: number, tripId: number,
@@ -3,7 +3,6 @@ import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-reac
import { type ReactNode, useEffect, useRef } from 'react'; import { type ReactNode, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import PlaceAvatar from '../shared/PlaceAvatar'; import PlaceAvatar from '../shared/PlaceAvatar';
import Tooltip from '../shared/Tooltip'; import Tooltip from '../shared/Tooltip';
@@ -46,11 +45,7 @@ export default function AssistantComposer({ tripId, tripTitle }: { tripId: numbe
const empty = useAuiState((s) => s.composer.isEmpty); const empty = useAuiState((s) => s.composer.isEmpty);
const { take, busy, accept, canAttach } = useAssistantAttach(tripId); const { take, busy, accept, canAttach } = useAssistantAttach(tripId);
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
// The home chat is about no one trip, so its placeholder names none. const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
const fallback =
tripId === HOME_CHAT_KEY
? t('assistant.home.placeholder')
: t('assistant.composer.placeholder', { trip: tripTitle });
const placeholder = useTypewriterPlaceholder( const placeholder = useTypewriterPlaceholder(
suggestions.map((s) => s.prompt), suggestions.map((s) => s.prompt),
fallback, fallback,
@@ -1,16 +1,4 @@
import { import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
Ellipsis,
History,
House,
Lightbulb,
LightbulbOff,
Luggage,
NotebookPen,
Plus,
Settings,
Trash2,
X,
} from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react'; import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -24,9 +12,7 @@ import Tooltip from '../shared/Tooltip';
import { PopoverListPanel } from '../shared/dialogParts'; import { PopoverListPanel } from '../shared/dialogParts';
import { usePopoverList } from '../shared/usePopoverList'; import { usePopoverList } from '../shared/usePopoverList';
import AssistantMemoryDialog from './AssistantMemoryDialog'; import AssistantMemoryDialog from './AssistantMemoryDialog';
import AssistantTripPicker from './AssistantTripPicker';
import { AI_SETTINGS_PATH } from './assistantParts'; import { AI_SETTINGS_PATH } from './assistantParts';
import type { AssistantChatTrip } from './assistantViewContext';
import { useAssistantHintsSetting } from './useAssistantHints'; import { useAssistantHintsSetting } from './useAssistantHints';
import { useAssistantLiveStep } from './useAssistantLiveStep'; import { useAssistantLiveStep } from './useAssistantLiveStep';
import type { useAssistantThread } from './useAssistantThread'; import type { useAssistantThread } from './useAssistantThread';
@@ -43,16 +29,6 @@ interface AssistantHeaderProps {
onClose: () => void; onClose: () => void;
/** Moves the window: spread on the title, which is the part of the header to grab. */ /** Moves the window: spread on the title, which is the part of the header to grab. */
dragProps: HTMLAttributes<HTMLElement>; dragProps: HTMLAttributes<HTMLElement>;
/**
* The way between the home chat and one trip's: the home chat picks a trip
* (`onPick`), a trip's chat opened from outside it goes back (`onHome`).
*/
tripSwitch?: TripSwitch;
}
interface TripSwitch {
onPick?: (trip: AssistantChatTrip) => void;
onHome?: () => void;
} }
/** /**
@@ -63,14 +39,7 @@ interface TripSwitch {
* keeps the conversation, and an answer being written goes on. The model is not picked here; * keeps the conversation, and an answer being written goes on. The model is not picked here;
* that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button. * that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button.
*/ */
export default function AssistantHeader({ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
tripId,
thread,
tripTitle,
onClose,
dragProps,
tripSwitch,
}: AssistantHeaderProps) {
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const live = useAssistantLiveStep(tripId); const live = useAssistantLiveStep(tripId);
const memory = useTripMemory(tripId); const memory = useTripMemory(tripId);
@@ -153,7 +122,6 @@ export default function AssistantHeader({
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
> >
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
{tripChat && ( {tripChat && (
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}> <Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
<button <button
@@ -250,49 +218,3 @@ export default function AssistantHeader({
</header> </header>
); );
} }
/**
* Between the home chat and a trip's: from the home chat a list of the user's
* trips (the running and the next one first) opens that trip's chat in the
* window; from a trip's chat opened outside it, one press goes back home.
*/
function TripSwitchButton({ onPick, onHome }: TripSwitch) {
const { t } = useTranslation();
const list = usePopoverList(0, { estimatedHeight: 360 });
const { open, triggerRef, listId, toggle, close } = list;
if (onHome) {
return (
<Tooltip label={t('assistant.home.back')}>
<button type="button" onClick={onHome} aria-label={t('assistant.home.back')} className={ICON_BUTTON}>
<House size={13} aria-hidden="true" />
</button>
</Tooltip>
);
}
if (!onPick) return null;
return (
<>
<Tooltip label={t('assistant.home.switch')}>
<button
ref={triggerRef}
type="button"
onClick={toggle}
aria-label={t('assistant.home.switch')}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={ICON_BUTTON}
>
<Luggage size={13} aria-hidden="true" />
</button>
</Tooltip>
<PopoverListPanel list={list} label={t('assistant.picker.title')} className="flex w-72 flex-col">
<AssistantTripPicker
onPick={({ id, title }) => {
close();
onPick({ id, title });
}}
/>
</PopoverListPanel>
</>
);
}
@@ -17,8 +17,7 @@ function useRouteTripId(): number | null {
/** /**
* The desktop assistant, mounted once above the routes so a conversation * The desktop assistant, mounted once above the routes so a conversation
* keeps going from page to page. Inside a trip it is about that trip, outside * keeps going from page to page. Inside a trip it is about that trip; what a
* one it is the home chat about all of them (AssistantWindow); what a
* card or an applied change opens goes through the planner's links: "Show on * card or an applied change opens goes through the planner's links: "Show on
* map" opens the planner on the change (`?place=`, `?day=`), the same way a * map" opens the planner on the change (`?place=`, `?day=`), the same way a
* notification opens a place, a tab of the trip opens as `?tab=` (a list's * notification opens a place, a tab of the trip opens as `?tab=` (a list's
@@ -101,14 +101,6 @@ describe('AssistantLauncher', () => {
act(() => useAssistantStore.getState().open()); act(() => useAssistantStore.getState().open());
expect(button).not.toHaveAttribute('aria-busy'); expect(button).not.toHaveAttribute('aria-busy');
}); });
it('FE-AI-LAUNCH-008: the floating button breathes for an answer of the home chat being written too', () => {
const home = assistantChatKey({ kind: 'home' });
const running = { ...chatOf(useAssistantStore.getState(), home), isRunning: true };
useAssistantStore.setState({ status: STATUS, byTrip: { [home]: running } });
render(<AssistantLauncher variant="floating" />);
expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute('aria-busy', 'true');
});
}); });
describe('the shortcut', () => { describe('the shortcut', () => {
@@ -15,7 +15,7 @@ export const LAUNCHER_SIZE = 48;
/** /**
* The way into the assistant: a button in the navbar, and in the planner a * The way into the assistant: a button in the navbar, and in the planner a
* round one floating at the bottom end of the screen. Both open and close the * round one floating at the bottom end of the screen. Both open and close the
* window; outside a trip it opens on the home chat. Nothing shows until the * window; outside a trip it opens on the trip picker. Nothing shows until the
* assistant has a connection this user can use: no setup prompt in its place. * assistant has a connection this user can use: no setup prompt in its place.
* The floating one carries a dot while the trip has a warning the user has * The floating one carries a dot while the trip has a warning the user has
* not been shown; opening the chat counts them as seen. * not been shown; opening the chat counts them as seen.
@@ -35,11 +35,11 @@ export default function AssistantLauncher({
const available = useAssistantStore((s) => s.status?.available === true); const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open && !s.window.minimized); const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
const toggle = useAssistantStore((s) => s.toggle); const toggle = useAssistantStore((s) => s.toggle);
// An answer still being written in a closed window: the floating button breathes until it is opened. A // An answer still being written in a closed window: the floating button breathes until it is opened. Only a
// trip's chat and the home chat count: an answer still running in the Studio is not one this window opens on. // trip's chat counts: an answer still running in the Studio is not one this window opens on.
const busy = useAssistantStore((s) => const busy = useAssistantStore((s) =>
!s.window.open || s.window.minimized !s.window.open || s.window.minimized
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind !== 'journey') ? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind === 'trip')
: false : false
); );
const keys = useAssistantShortcutKeys(); const keys = useAssistantShortcutKeys();
@@ -1,5 +1,5 @@
import type { AssistantFoundPlace } from '@trek/shared'; import type { AssistantFoundPlace } from '@trek/shared';
import { Check, MapPin, Plus, Route, Star } from 'lucide-react'; import { Check, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react'; import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -25,24 +25,6 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
); );
} }
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
if (!strip.tour) return null;
return (
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
<button
type="button"
onClick={() => strip.addToTour(place)}
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
>
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
<span className="truncate">{t('assistant.place.addToTour')}</span>
</button>
</Tooltip>
);
}
type Category = ReturnType<Strip['categoryOf']>; type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */ /** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
@@ -203,9 +185,8 @@ export default function AssistantPlaceCard({
)} )}
</span> </span>
</button> </button>
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5"> <div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} /> <PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
<TourButton place={place} strip={strip} tall={tall} />
</div> </div>
</li> </li>
); );
@@ -21,7 +21,6 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey'; import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode'; import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip'; import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -106,7 +105,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true); useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver; globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false); setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -240,22 +238,3 @@ describe('AssistantPlaceStrip', () => {
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
}); });
}); });
describe('"+ Tour" on a found place (P6)', () => {
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
seed();
const insertFoundPlace = vi.fn(async () => 'held' as const);
useAssistantStore.setState({ insertFoundPlace });
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
});
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
seed();
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
});
});
@@ -29,7 +29,6 @@ export default function AssistantSurface({
onFocusDay, onFocusDay,
onRevealMap, onRevealMap,
bookVersion, bookVersion,
onOpenTripChat,
}: AssistantSurfaceProps) { }: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId); const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says. // The phone is always compact; a window is what its width says.
@@ -42,9 +41,8 @@ export default function AssistantSurface({
onFocusDay, onFocusDay,
onRevealMap, onRevealMap,
bookVersion, bookVersion,
onOpenTripChat,
}), }),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat] [tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
); );
return ( return (
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
@@ -6,9 +6,8 @@ import { Spinner } from '../shared/Spinner';
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker'; import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
/** /**
* The user's trips to go on with in one trip's own chat, from the home chat's * Outside a trip the assistant first asks which one: the chat, its history
* head: that chat, its history and its changes belong to the trip. The * and its changes all belong to one trip. The running and the next trip lead.
* running and the next trip lead.
*/ */
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) { export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -28,15 +28,20 @@ export default function AssistantWelcome({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { tripId, variant } = useAssistantView(); const { tripId, variant } = useAssistantView();
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels. // The Studio's chat is about a book, not a plan.
const scope = useAssistantScope().kind; const book = useAssistantScope().kind === 'journey';
const book = scope === 'journey';
const hints = useAssistantHints(tripId, { withStays: true }); const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS; const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions; const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6"> <div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} /> <EmptyState
scene="chat"
size={72}
compact
surface="var(--bg-elevated)"
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
/>
{!canEdit && ( {!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted"> <p className="m-0 max-w-xs text-center text-caption text-content-muted">
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')} {book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
@@ -48,12 +53,6 @@ export default function AssistantWelcome({
); );
} }
/** The question over the empty chat, by what the chat is about. */
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
if (scope === 'journey') return t('journey.studio.assistant.welcome');
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
}
export function AssistantSuggestions({ export function AssistantSuggestions({
suggestions, suggestions,
layout, layout,
@@ -1,9 +1,8 @@
/** /**
* FE-AI-WIN-001 to FE-AI-WIN-014 * FE-AI-WIN-001 to FE-AI-WIN-012
* *
* The desktop window around the chat: when it shows at all, the button it * The desktop window around the chat: when it shows at all, the button it
* opens from, the home chat outside a trip with its way into one trip's chat * opens from, the trip picker outside a trip, closing that keeps the chat, and
* and back, closing that keeps the chat, and
* Escape, which stops an answer before it closes anything (and leaves the * Escape, which stops an answer before it closes anything (and leaves the
* header's menu to itself), and where the focus goes on the way; a trip's head * header's menu to itself), and where the focus goes on the way; a trip's head
* offers the notes its turns read. The geometry is * offers the notes its turns read. The geometry is
@@ -37,14 +36,12 @@ vi.mock('./AssistantSurfaceLazy', () => ({
density, density,
onFocusDay, onFocusDay,
onOpen, onOpen,
onOpenTripChat,
}: { }: {
tripId: number; tripId: number;
tripTitle: string; tripTitle: string;
density?: string; density?: string;
onFocusDay?: (dayId: number) => void; onFocusDay?: (dayId: number) => void;
onOpen?: (target: EntityTarget) => void; onOpen?: (target: EntityTarget) => void;
onOpenTripChat?: (trip: { id: number; title: string }, question: string | null) => void;
}) => ( }) => (
<div> <div>
<p> <p>
@@ -69,11 +66,6 @@ vi.mock('./AssistantSurfaceLazy', () => ({
</button> </button>
</> </>
)} )}
{onOpenTripChat && (
<button type="button" onClick={() => onOpenTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
go on in Lisbon
</button>
)}
<textarea aria-label="composer" /> <textarea aria-label="composer" />
</div> </div>
), ),
@@ -144,36 +136,12 @@ describe('AssistantWindow', () => {
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it('FE-AI-WIN-004: outside a trip it opens on the home chat, whose head opens one trip chat and goes back', async () => { it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
seed({ open: true, minimized: false }); seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />); render(<AssistantWindow trip={null} />);
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
expect(resume).toHaveBeenCalledWith(0);
// The home chat has no trip notes.
expect(screen.queryByRole('button', { name: 'Trip notes' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ })); fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument(); expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back to all trips' }));
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
});
it("FE-AI-WIN-013: the home chat's card goes on in the trip's chat with the question waiting there", () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
fireEvent.click(screen.getByRole('button', { name: 'go on in Lisbon' }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
// In a trip's chat there is no card to go on from.
expect(screen.queryByRole('button', { name: 'go on in Lisbon' })).not.toBeInTheDocument();
});
it('FE-AI-WIN-014: minimized outside a trip, the round button brings the home chat back', () => {
seed({ open: true, minimized: true });
render(<AssistantWindow trip={null} />);
expect(screen.getByText('chat 0 Your travels')).not.toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window.minimized).toBe(false);
}); });
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => { it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
@@ -270,10 +238,6 @@ describe('AssistantWindow', () => {
<Location /> <Location />
</> </>
); );
// From the home chat only another page opens.
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ })); fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
fireEvent.click(screen.getByRole('button', { name: 'open tab' })); fireEvent.click(screen.getByRole('button', { name: 'open tab' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3'); expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
@@ -1,21 +1,20 @@
import './assistant.css'; import './assistant.css';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize'; import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore'; import { useAssistantStore } from '../../store/assistantStore';
import AssistantFrameEdges from './AssistantFrameEdges'; import AssistantFrameEdges from './AssistantFrameEdges';
import AssistantHeader from './AssistantHeader'; import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher'; import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy'; import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame'; import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
import { plannerSearchOf } from './assistantShowOnMap'; import { plannerSearchOf } from './assistantShowOnMap';
import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes'; import type { EntityTarget } from './cards/cardTypes';
import { useAssistantHome } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey'; import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread'; import { useAssistantThread } from './useAssistantThread';
@@ -31,18 +30,17 @@ import { useAssistantThread } from './useAssistantThread';
* an answer is still being written. Escape stops an answer first, then * an answer is still being written. Escape stops an answer first, then
* closes, and the focus goes back to where it was. * closes, and the focus goes back to where it was.
* *
* Outside a trip it opens on the home chat, which knows all of the user's
* trips; its head lists them to go on in one trip's own chat, and a trip's
* chat opened that way has the way back home in its head.
*
* Mounted once for the whole app; it shows nothing until the assistant has a * Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use. * connection this user can use.
*/ */
type WindowTrip = AssistantChatTrip; interface WindowTrip {
id: number;
title: string;
}
interface AssistantWindowProps { interface AssistantWindowProps {
/** The trip on screen; null outside a trip, where the window opens on the home chat. */ /** The trip on screen; null outside a trip, where the window asks which one first. */
trip: WindowTrip | null; trip: WindowTrip | null;
/** Opens what a card or an applied change points at, from the trip on screen. */ /** Opens what a card or an applied change points at, from the trip on screen. */
onOpen?: (target: EntityTarget) => void; onOpen?: (target: EntityTarget) => void;
@@ -72,16 +70,12 @@ function ChatWindow({
onOpen, onOpen,
onFocusDay, onFocusDay,
onRevealMap, onRevealMap,
onOpenTripChat,
tripSwitch,
}: { }: {
trip: WindowTrip; trip: WindowTrip;
hidden: boolean; hidden: boolean;
onOpen?: (target: EntityTarget) => void; onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void; onFocusDay?: (dayId: number) => void;
onRevealMap?: () => void; onRevealMap?: () => void;
onOpenTripChat?: (trip: WindowTrip, question: string | null) => void;
tripSwitch?: Parameters<typeof AssistantHeader>[0]['tripSwitch'];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const thread = useAssistantThread(trip.id); const thread = useAssistantThread(trip.id);
@@ -156,7 +150,6 @@ function ChatWindow({
thread={thread} thread={thread}
tripTitle={trip.title} tripTitle={trip.title}
dragProps={frame.handleProps} dragProps={frame.handleProps}
tripSwitch={tripSwitch}
// Closing only hides the window: an answer being written keeps coming in, // Closing only hides the window: an answer being written keeps coming in,
// and the floating button says so until it is opened again. // and the floating button says so until it is opened again.
onClose={() => setMinimized(true)} onClose={() => setMinimized(true)}
@@ -169,7 +162,6 @@ function ChatWindow({
onOpen={onOpen} onOpen={onOpen}
onFocusDay={onFocusDay} onFocusDay={onFocusDay}
onRevealMap={onRevealMap} onRevealMap={onRevealMap}
onOpenTripChat={onOpenTripChat}
/> />
</div> </div>
</section> </section>
@@ -185,21 +177,19 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
const toggle = useAssistantStore((s) => s.toggle); const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null); const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open); const [wasOpen, setWasOpen] = useState(open);
const home = useAssistantHome(setPicked);
useEffect(() => { useEffect(() => {
void loadStatus(); void loadStatus();
}, [loadStatus]); }, [loadStatus]);
useAssistantHotkey(available, toggle); useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again. // A trip picked outside the planner is only for this opening; the next one asks again.
if (open !== wasOpen) { if (open !== wasOpen) {
setWasOpen(open); setWasOpen(open);
if (!open) setPicked(null); if (!open) setPicked(null);
} }
const current = trip ?? picked ?? home.chat; const current = trip ?? picked;
const atHome = current.id === HOME_CHAT_KEY;
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it. // A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
const openPicked = useCallback( const openPicked = useCallback(
(target: EntityTarget) => { (target: EntityTarget) => {
@@ -208,14 +198,6 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
}, },
[navigate, picked] [navigate, picked]
); );
// The home chat has no trip to show anything in; another page it simply opens.
const openFromHome = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
},
[navigate]
);
const goHome = useCallback(() => setPicked(null), []);
// Another page leaves the trip: the chat stays on it, as if it had been picked. // Another page leaves the trip: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback( const openOnScreen = useCallback(
(target: EntityTarget) => { (target: EntityTarget) => {
@@ -227,27 +209,52 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
if (!available) return null; if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null; if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!current) return <PickerWindow onPick={setPicked} />;
const onScreen = !!trip && current.id === trip.id; const onScreen = !!trip && current.id === trip.id;
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
if (atHome) openTarget = openFromHome;
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
if (atHome) tripSwitch = { onPick: setPicked };
else if (!onScreen) tripSwitch = { onHome: goHome };
return ( return (
<> <>
<ChatWindow <ChatWindow
trip={current} trip={current}
hidden={minimized} hidden={minimized}
onOpen={openTarget} onOpen={onScreen ? onOpen && openOnScreen : openPicked}
// Only the planner on screen has days to select and a map to bring back. // Only the planner on screen has days to select and a map to bring back.
onFocusDay={onScreen ? onFocusDay : undefined} onFocusDay={onScreen ? onFocusDay : undefined}
onRevealMap={onScreen ? onRevealMap : undefined} onRevealMap={onScreen ? onRevealMap : undefined}
onOpenTripChat={atHome ? home.openTripChat : undefined}
tripSwitch={tripSwitch}
/> />
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */} {minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
</> </>
); );
} }
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
const { t } = useTranslation();
const close = useAssistantStore((s) => s.close);
return (
<section
role="dialog"
aria-labelledby="assistant-picker-title"
onKeyDown={(e) => {
if (e.key === 'Escape' && !e.defaultPrevented) close();
}}
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
>
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
{t('assistant.picker.title')}
</h2>
<button
type="button"
onClick={close}
aria-label={t('common.close')}
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={16} aria-hidden="true" />
</button>
</header>
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
</section>
);
}
@@ -107,7 +107,6 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
decide_first: { messageKey: 'assistant.error.decideFirst', action: null }, decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null }, too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null }, no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null }, import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: { generic: {
messageKey: 'assistant.error.generic', messageKey: 'assistant.error.generic',
@@ -225,17 +224,6 @@ const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
const MAX_SUGGESTIONS = 4; const MAX_SUGGESTIONS = 4;
/**
* What the empty home chat offers, outside any one trip: questions across the
* user's trips and about the parts of TREK that belong to none of them.
*/
export const HOME_SUGGESTIONS: readonly AssistantSuggestion[] = [
{ id: 'homeNext', labelKey: 'assistant.suggest.homeNext' },
{ id: 'homeCompare', labelKey: 'assistant.suggest.homeCompare' },
{ id: 'homeAtlas', labelKey: 'assistant.suggest.homeAtlas' },
{ id: 'homeVacay', labelKey: 'assistant.suggest.homeVacay' },
];
/** /**
* The questions the empty chat offers: about the place or day in focus first, * The questions the empty chat offers: about the place or day in focus first,
* then what fits the trip's phase. The label is also the prompt, so a tapped * then what fits the trip's phase. The label is also the prompt, so a tapped
@@ -127,10 +127,6 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'propose_hike', 'propose_hike',
'create_hike', 'create_hike',
'propose_tour',
'create_tour',
'create_paddle_tour',
'create_ski_tour',
'update_tour', 'update_tour',
'propose_city_walk', 'propose_city_walk',
'create_city_walk', 'create_city_walk',
@@ -138,9 +134,6 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'plan_ski_route', 'plan_ski_route',
'prepare_tour_change', 'prepare_tour_change',
'save_tour_change', 'save_tour_change',
'propose_round_hike',
'propose_hike_leg_way',
'import_wanderer_trail',
]); ]);
/** The tool that keeps a note in the trip notes; kept at once, without a card. */ /** The tool that keeps a note in the trip notes; kept at once, without a card. */
@@ -462,5 +455,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
difficulty: { labelKey: 'assistant.change.label.difficulty' }, difficulty: { labelKey: 'assistant.change.label.difficulty' },
track: { labelKey: 'assistant.change.label.track' }, track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' }, packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
}; };
@@ -34,17 +34,6 @@ export interface AssistantView {
* version the trial ran on. * version the trial ran on.
*/ */
bookVersion?: number | null; bookVersion?: number | null;
/**
* Goes on in a trip's own chat with a question waiting there, from the home
* chat's card that offers it; left out where no chat can be switched to.
*/
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
}
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
export interface AssistantChatTrip {
id: number;
title: string;
} }
export const AssistantViewContext = createContext<AssistantView | null>(null); export const AssistantViewContext = createContext<AssistantView | null>(null);
@@ -57,11 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' }, font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' }, palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' }, repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' },
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
}; };
/** A page as the Studio names it: the cover, the first page, double page 3. */ /** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -37,7 +37,6 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it. // The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' }, preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' }, list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookStickers' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' }, suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
}, },
entities: { entities: {
@@ -1,4 +1,4 @@
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react'; import { BookmarkMinus, BookmarkPlus, Copy, Ruler, Sparkles } from 'lucide-react';
import type { PackLook } from '../cardTypes'; import type { PackLook } from '../cardTypes';
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
verbs: { verbs: {
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' }, designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' }, setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' },
setBookTheme: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setBookTheme' },
duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' }, duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' },
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' }, saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' }, removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' },
@@ -24,19 +24,6 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
journal: { labelKey: 'assistant.card.bookDesign.style.journal' }, journal: { labelKey: 'assistant.card.bookDesign.style.journal' },
bold: { labelKey: 'assistant.card.bookDesign.style.bold' }, bold: { labelKey: 'assistant.card.bookDesign.style.bold' },
scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' }, scrapbook: { labelKey: 'assistant.card.bookDesign.style.scrapbook' },
// A design in one of the themes is called by the theme's own name.
'poster-pop': { labelKey: 'studio.theme.name.posterPop' },
cutout: { labelKey: 'studio.theme.name.cutout' },
postcard: { labelKey: 'studio.theme.name.postcard' },
riviera: { labelKey: 'studio.theme.name.riviera' },
'fabric-classic': { labelKey: 'studio.theme.name.fabricClassic' },
'minimal-serif': { labelKey: 'studio.theme.name.minimalSerif' },
'grid-badge': { labelKey: 'studio.theme.name.gridBadge' },
'scrapbook-script': { labelKey: 'studio.theme.name.scrapbookScript' },
tropical: { labelKey: 'studio.theme.name.tropical' },
nordic: { labelKey: 'studio.theme.name.nordic' },
alpine: { labelKey: 'studio.theme.name.alpine' },
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
}; };
type Translate = (key: string, params?: Record<string, string | number>) => string; type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -28,7 +28,6 @@ import { BUDGET_LOOK } from './budget/budgetLook';
import type { CardLook, EntityLook, StepLook, VerbLook } from './cardTypes'; import type { CardLook, EntityLook, StepLook, VerbLook } from './cardTypes';
import { COLLAB_LOOK } from './collab/collabLook'; import { COLLAB_LOOK } from './collab/collabLook';
import { COLLECTION_LOOK } from './collection/collectionLook'; import { COLLECTION_LOOK } from './collection/collectionLook';
import { HOME_LOOK } from './home/homeLook';
import { JOURNEY_LOOK } from './journey/journeyLook'; import { JOURNEY_LOOK } from './journey/journeyLook';
import { PACKING_LOOK } from './packing/packingLook'; import { PACKING_LOOK } from './packing/packingLook';
import { ROADTRIP_LOOK } from './roadtrip/roadtripLook'; import { ROADTRIP_LOOK } from './roadtrip/roadtripLook';
@@ -171,7 +170,6 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
...PACKING_LOOK.steps, ...PACKING_LOOK.steps,
...BOOK_LOOK.steps, ...BOOK_LOOK.steps,
...BOOK_DESIGN_LOOK.steps, ...BOOK_DESIGN_LOOK.steps,
...HOME_LOOK.steps,
}; };
/** Shown on the map by its day: a stop or a note lives on one. */ /** Shown on the map by its day: a stop or a note lives on one. */
@@ -236,7 +234,6 @@ export const CARD_LOOKS: { readonly [K in TrekCardKind]: CardLook<K> } = {
...PACKING_LOOK.cards, ...PACKING_LOOK.cards,
...BOOK_LOOK.cards, ...BOOK_LOOK.cards,
...BOOK_DESIGN_LOOK.cards, ...BOOK_DESIGN_LOOK.cards,
...HOME_LOOK.cards,
}; };
/** The look of the card in hand, typed for any kind. */ /** The look of the card in hand, typed for any kind. */
@@ -8,7 +8,6 @@ import { BUDGET_VIEWS } from './budget/budgetViews';
import type { CardView } from './cardTypes'; import type { CardView } from './cardTypes';
import { COLLAB_VIEWS } from './collab/collabViews'; import { COLLAB_VIEWS } from './collab/collabViews';
import { COLLECTION_VIEWS } from './collection/collectionViews'; import { COLLECTION_VIEWS } from './collection/collectionViews';
import { HOME_VIEWS } from './home/homeViews';
import { JOURNEY_VIEWS } from './journey/journeyViews'; import { JOURNEY_VIEWS } from './journey/journeyViews';
import { PACKING_VIEWS } from './packing/packingViews'; import { PACKING_VIEWS } from './packing/packingViews';
import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews'; import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews';
@@ -34,7 +33,6 @@ export const CARD_VIEWS: { readonly [K in TrekCardKind]: CardView<K> } = {
...PACKING_VIEWS, ...PACKING_VIEWS,
...BOOK_VIEWS, ...BOOK_VIEWS,
...BOOK_DESIGN_VIEWS, ...BOOK_DESIGN_VIEWS,
...HOME_VIEWS,
}; };
/** The view of the card in hand, typed for any kind. */ /** The view of the card in hand, typed for any kind. */
@@ -1,85 +0,0 @@
/**
* FE-AI-TRIPCHAT-001 to FE-AI-TRIPCHAT-004
*
* The home chat's card that offers a trip's own chat: the trip under its head
* with its dates, where it stands and its places, the question waiting there,
* and the button that goes on in that chat, shown only where a chat can be
* switched to.
*/
import type { TripChatCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import type { CardEntry } from '../cardTypes';
import { TripChatBlock } from './TripChatBlock';
const CARD: TripChatCard = {
kind: 'tripChat',
trip: { id: 5, title: 'Kyoto', startDate: '2026-11-02', endDate: '2026-11-09', phase: 'next', placeCount: 12 },
question: 'What is still missing?',
};
function entry(card: TripChatCard, toolCallId = 'c1'): CardEntry {
return { toolCallId, card };
}
function show(cards: CardEntry[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 0, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<TripChatBlock cards={cards} />
</AssistantViewContext.Provider>
);
}
describe('TripChatBlock', () => {
it('FE-AI-TRIPCHAT-001: shows the trip, where it stands, its places and the question', () => {
show([entry(CARD)]);
expect(screen.getByRole('region', { name: 'Continue in a trip chat' })).toBeInTheDocument();
expect(screen.getByText('Kyoto')).toBeInTheDocument();
expect(screen.getByText('Trip chat')).toBeInTheDocument();
// The dates, not the hidden label of the question, which has a "to" of its own.
expect(screen.getByText(/\d to \w/)).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.getByText('12 places')).toBeInTheDocument();
expect(screen.getByText('What is still missing?')).toBeInTheDocument();
});
it("FE-AI-TRIPCHAT-002: the button goes on in the trip's chat with the question", () => {
const onOpenTripChat = vi.fn();
show([entry(CARD)], { onOpenTripChat });
fireEvent.click(screen.getByRole('button', { name: "Continue in this trip's chat" }));
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 5, title: 'Kyoto' }, 'What is still missing?');
});
it('FE-AI-TRIPCHAT-003: no button where no chat can be switched to, and a trip without dates says so', () => {
const bare: TripChatCard = {
kind: 'tripChat',
trip: { id: 6, title: 'Someday', startDate: null, endDate: null, phase: 'undated', placeCount: null },
question: null,
};
show([entry(bare)]);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText('No dates yet')).toBeInTheDocument();
expect(screen.queryByText('Next')).not.toBeInTheDocument();
});
it('FE-AI-TRIPCHAT-004: two trips sit in one card, each with its own button; nothing to show draws nothing', () => {
const onOpenTripChat = vi.fn();
const other: TripChatCard = {
...CARD,
trip: { ...CARD.trip, id: 7, title: 'Oslo', phase: 'now', startDate: '2026-10-20', endDate: '2026-10-20' },
question: null,
};
show([entry(CARD), entry(other, 'c2')], { onOpenTripChat, variant: 'fullscreen' });
const buttons = screen.getAllByRole('button', { name: "Continue in this trip's chat" });
expect(buttons).toHaveLength(2);
fireEvent.click(buttons[1]);
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 7, title: 'Oslo' }, null);
expect(screen.getByText('Under way')).toBeInTheDocument();
const { container } = render(<TripChatBlock cards={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,114 +0,0 @@
import type { TripChatCard, TripChatPhase } from '@trek/shared';
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { useAssistantView } from '../../assistantViewContext';
import type { CardEntry } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { useCardFormat } from '../parts/useCardFormat';
/**
* The home chat's way into one trip's own chat: the trip under its head (its
* dates, where it stands, how many places it holds), the question that waits
* there ready to send, and the button that goes on in that chat. The button
* shows only where a chat can be switched to; the window switches in place,
* the phone opens the trip with its chat.
*/
/** Where the trip stands, as a pill; a trip without dates says so on its line instead. */
const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: string; tone: PillTone }>> = {
now: { labelKey: 'assistant.card.tripChat.phase.now', tone: 'success' },
next: { labelKey: 'assistant.card.tripChat.phase.next', tone: 'accent' },
upcoming: { labelKey: 'assistant.card.tripChat.phase.upcoming', tone: 'info' },
past: { labelKey: 'assistant.card.tripChat.phase.past', tone: 'neutral' },
};
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
const { t } = useTranslation();
const { onOpenTripChat, variant } = useAssistantView();
const format = useCardFormat();
const { trip, question } = card;
const start = trip.startDate ?? trip.endDate;
const end = trip.endDate ?? trip.startDate;
let dates = t('assistant.card.tripChat.noDates');
if (start && end) {
dates =
start === end
? format.date(start)
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
}
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
return (
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
{first ? (
<CardHeader
icon={Luggage}
tone="info"
eyebrow={t('assistant.card.tripChat.eyebrow')}
title={trip.title}
meta={dates}
>
<TripFacts phase={phase} placeCount={trip.placeCount} />
</CardHeader>
) : (
<div className="mb-2.5">
<bdi className="block truncate text-body font-bold text-content">{trip.title}</bdi>
<div className="mt-0.5 text-caption text-content-muted">{dates}</div>
<TripFacts phase={phase} placeCount={trip.placeCount} />
</div>
)}
{question && (
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
</p>
)}
{onOpenTripChat && (
<button
type="button"
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
variant === 'fullscreen' ? 'h-11' : 'h-9'
}`}
>
{t('assistant.card.tripChat.open')}
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
)}
</div>
);
}
/** The pills under the trip's name: where it stands and how many places it holds. */
function TripFacts({
phase,
placeCount,
}: {
phase: { labelKey: string; tone: PillTone } | null;
placeCount: number | null;
}) {
const { t } = useTranslation();
if (!phase && !placeCount) return null;
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{phase && <SoftPill tone={phase.tone}>{t(phase.labelKey)}</SoftPill>}
{!!placeCount && <WhiteBadge>{t('collections.placeCount', { count: placeCount })}</WhiteBadge>}
</div>
);
}
export function TripChatBlock({ cards }: { cards: readonly CardEntry[] }) {
const { t } = useTranslation();
const trips = cards.flatMap((entry) => (entry.card.kind === 'tripChat' ? [entry.card] : []));
if (trips.length === 0) return null;
return (
<CardFrame label={t('assistant.card.tripChat.label')}>
{trips.map((card, index) => (
<TripChatSection key={`${card.trip.id}-${index}`} card={card} first={index === 0} />
))}
</CardFrame>
);
}
@@ -1,21 +0,0 @@
import type { PackLook } from '../cardTypes';
/**
* What the home chat (the assistant outside any one trip) brings to the
* chat's pure look: the words its reads run under and the look of the card
* that offers a trip's own chat. It has no verbs or entities of its own; the
* changes it proposes are the atlas', the vacation planner's and the
* collections'. A look stays pure, literals only.
*/
export const HOME_LOOK = {
verbs: {},
tools: {},
steps: {
list_trips: { stepKey: 'assistant.step.readingTrips' },
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
},
entities: {},
cards: {
tripChat: {},
},
} satisfies PackLook<'tripChat'>;
@@ -1,12 +0,0 @@
import type { CardView } from '../cardTypes';
import { TripChatBlock } from './TripChatBlock';
/**
* How the home chat's kind draws: the trip it offers to go on with, beside
* the answer. This file only maps the kind to its view; the component lives
* in a sibling file, since a file that exports anything besides components
* breaks fast refresh.
*/
export const HOME_VIEWS = {
tripChat: { Block: TripChatBlock },
} satisfies { [K in 'tripChat']: CardView<K> };
@@ -20,16 +20,7 @@ import { ROW_GLYPH, type RowChange } from './checklistModel';
* list, and the rows of a run of changes with their include choice. * list, and the rows of a run of changes with their include choice.
*/ */
/** The band behind a group's head: its colour faint over the card's grey, the grey alone without one. */ /** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
function bandOf(color: string): string {
return color ? `color-mix(in srgb, ${color} 12%, var(--bg-secondary))` : 'var(--bg-secondary)';
}
/**
* A group's head as a section of the list: a full width band in its colour,
* the colour dot, its name, and at the end its badge (how many it holds) and
* who looks after it, the ones a change drops faded.
*/
export function ChecklistHead({ export function ChecklistHead({
color, color,
name, name,
@@ -47,17 +38,14 @@ export function ChecklistHead({
lead?: ReactNode; lead?: ReactNode;
}) { }) {
return ( return (
<div <div className="flex min-w-0 items-center gap-2 pt-1">
className="mt-1.5 flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: bandOf(color) }}
>
{lead ?? <CategoryDot color={color} />} {lead ?? <CategoryDot color={color} />}
<bdi title={name} className="min-w-0 flex-1 truncate text-caption font-semibold text-content"> <bdi title={name} className="min-w-0 truncate text-caption font-semibold text-content">
{name} {name}
</bdi> </bdi>
{(badge || people.length > 0 || removed.length > 0) && ( {badge}
<span className="flex flex-none items-center gap-1.5"> {(people.length > 0 || removed.length > 0) && (
{badge} <span className="ms-auto flex flex-none items-center gap-1.5">
<PeopleStack people={people} /> <PeopleStack people={people} />
{/* The people a change takes off, faded; the card says "No longer" in words under the head. */} {/* The people a change takes off, faded; the card says "No longer" in words under the head. */}
{removed.length > 0 && ( {removed.length > 0 && (
@@ -58,13 +58,13 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
/** A row's quantity and weight: quiet figures at its end, no frame each. */ /** A row's quantity and weight: quiet figures at its end, no frame each. */
const QUIET = 'text-caption tabular-nums text-content-muted'; const QUIET = 'text-caption tabular-nums text-content-muted';
/** How many rows a group holds, a white pill at the end of its band. */ /** How many rows a group holds, quiet beside its name. */
function GroupCount({ count }: { count: number }) { function GroupCount({ count }: { count: number }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span <span
aria-label={t('tours.packing.items', { count })} aria-label={t('tours.packing.items', { count })}
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 text-caption font-semibold tabular-nums text-content-secondary shadow-sm" className="flex-none text-caption tabular-nums text-content-faint"
> >
{count} {count}
</span> </span>
@@ -231,7 +231,7 @@ function BagHead({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!bag) { if (!bag) {
const badge = <GroupCount count={group.count} />; const badge = inChange ? undefined : <GroupCount count={group.count} />;
const lead = <BagSwatch bag={undefined} size="head" />; const lead = <BagSwatch bag={undefined} size="head" />;
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />; return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
} }
@@ -298,7 +298,7 @@ export function PackingGroup({
<ChecklistHead <ChecklistHead
color={packingColorOf(key.slot, name)} color={packingColorOf(key.slot, name)}
name={name} name={name}
badge={<GroupCount count={group.count} />} badge={inChange ? undefined : <GroupCount count={group.count} />}
people={group.assignees} people={group.assignees}
removed={group.assigneesRemoved} removed={group.assigneesRemoved}
/> />
@@ -5,7 +5,6 @@ import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore'; import { useSettingsStore } from '../../../../store/settingsStore';
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters'; import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../../utils/units'; import { formatDistance, formatElevation } from '../../../../utils/units';
import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
/** /**
* How a card writes its numbers, the way the rest of TREK does for this * How a card writes its numbers, the way the rest of TREK does for this
@@ -14,7 +13,7 @@ import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
* units. A card hands these to its words as `{value}`, never a raw number. * units. A card hands these to its words as `{value}`, never a raw number.
*/ */
export function useCardFormat() { export function useCardFormat() {
const { locale, t } = useTranslation(); const { locale } = useTranslation();
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h'); const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric'); const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
return useMemo(() => { return useMemo(() => {
@@ -38,8 +37,6 @@ export function useCardFormat() {
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`; return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
}, },
count: (value: number) => number.format(value), count: (value: number) => number.format(value),
/** "5:30 min/km" from seconds per kilometre, per mile with distances in miles. */
pace: (secondsPerKm: number) => `${formatRunPace(secondsPerKm, unit)} ${runPaceUnitText(unit, t)}`,
}; };
}, [locale, twelveHour, unit, t]); }, [locale, twelveHour, unit]);
} }
@@ -1,5 +1,5 @@
/** /**
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062 * FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
* *
* A route in the chat as the Tours tab shows a hike: its figures and grade, * A route in the chat as the Tours tab shows a hike: its figures and grade,
* a profile only where the heights are known, the profile, the stop chips * a profile only where the heights are known, the profile, the stop chips
@@ -387,44 +387,4 @@ describe('TourRouteCard', () => {
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2); expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1); expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
}); });
it('FE-AI-TOUR-061: a bike tour names its profile and its riding time, a run its pace, and waits for one it lacks', () => {
const bike: Route = {
...HIKE,
activity: 'bike',
routingProfile: 'gravel',
minutesBasis: 'router',
limit: null,
grades: null,
};
const { container } = renderCard(bike);
expect(screen.getByText('Gravel')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
expect(container.querySelector('[data-field="pace"]')).toBeNull();
const run: Route = {
...bike,
activity: 'run',
routingProfile: 'trail',
minutesBasis: 'pace',
paceSecondsPerKm: 330,
};
const paced = renderCard(run);
expect(screen.getByText('Trail running')).toBeInTheDocument();
expect(paced.container.querySelector('[data-field="pace"]')).toHaveTextContent('5:30 min/km');
renderCard({ ...run, paceSecondsPerKm: null, minutes: null });
expect(screen.getByText('Tell the assistant your pace to save this run.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Plan this' });
expect(buttons[buttons.length - 1]).toBeDisabled();
});
it('FE-AI-TOUR-062: a whole paddle plan offers "Plan this"; one with a leg that failed does not', () => {
const plan: Route = { ...HIKE, activity: 'kayak', source: 'plan', limit: null, grades: null, schedule: null };
renderCard(plan);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeInTheDocument();
renderCard({ ...plan, name: 'Broken', warnings: [{ kind: 'legs_failed', at: null, count: 1 }] });
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
});
}); });
@@ -16,7 +16,7 @@ import RouteSketch from '../parts/RouteSketch';
import { useCardFormat } from '../parts/useCardFormat'; import { useCardFormat } from '../parts/useCardFormat';
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel'; import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel'; import { sketchOf } from './tourRouteSketchModel';
import { PROFILE_WORDS, routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords'; import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
/** /**
* The head of a route card: the route drawn small beside its name, the * The head of a route card: the route drawn small beside its name, the
@@ -63,16 +63,14 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
/** /**
* The grade of a hike, as the hike's own card shows it: the hardest SAC step * The grade of a hike, as the hike's own card shows it: the hardest SAC step
* along the way, uncertain when too little of it is graded, with what it * along the way, uncertain when too little of it is graded, with what it
* means, the routing limit and where grades come from in its tooltip. A bike * means, the routing limit and where grades come from in its tooltip. Any
* tour or a run names its profile instead, any other tour what kind it is. * other tour says what kind it is instead.
*/ */
export function TourGrade({ card }: { card: TourRouteCard }) { export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) { if (card.activity !== 'hike' || !card.grades) {
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true }); const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
// A bike tour or a run says what it was planned for: "Gravel", "Trail running". return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
} }
const { highest, unknownShare } = card.grades; const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5; const uncertain = unknownShare >= 0.5;
@@ -140,7 +138,7 @@ export function TourRouteSketch({
export function TourRouteFields({ card }: { card: TourRouteCard }) { export function TourRouteFields({ card }: { card: TourRouteCard }) {
const { t } = useTranslation(); const { t } = useTranslation();
const format = useCardFormat(); const format = useCardFormat();
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => { const fields = factKeysOf(card).flatMap((key) => {
const value = factValue(card, key); const value = factValue(card, key);
if (value === null) return []; if (value === null) return [];
// A field's label is short; how the time was reckoned is the figure badges' tooltip. // A field's label is short; how the time was reckoned is the figure badges' tooltip.
@@ -150,10 +148,6 @@ export function TourRouteFields({ card }: { card: TourRouteCard }) {
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value); else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
return [{ key, label, value: text }]; return [{ key, label, value: text }];
}); });
// A run's time is its distance at the runner's pace: the pace stands beside it.
if (card.paceSecondsPerKm != null) {
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
}
return <CardFields fields={fields} />; return <CardFields fields={fields} />;
} }
@@ -179,9 +173,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
title={name} title={name}
meta={card.label ?? undefined} meta={card.label ?? undefined}
> >
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && ( {((card.activity === 'hike' && card.grades) || card.schedule) && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5"> <div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />} {card.activity === 'hike' && card.grades && <TourGrade card={card} />}
<DayPill card={card} /> <DayPill card={card} />
</div> </div>
)} )}
@@ -1,5 +1,5 @@
/** /**
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063 * FE-AI-TOUR-033 to FE-AI-TOUR-034
* *
* A held tour change in its row: the route drawn small with its figures and * A held tour change in its row: the route drawn small with its figures and
* grade and a small profile, the kind of tour as the row's tile; a tour whose * grade and a small profile, the kind of tour as the row's tile; a tour whose
@@ -71,10 +71,4 @@ describe('TourRouteInRow', () => {
expect(delta).toHaveTextContent('+1.6 km'); expect(delta).toHaveTextContent('+1.6 km');
expect(delta).toHaveTextContent('+20 min'); expect(delta).toHaveTextContent('+20 min');
}); });
it('FE-AI-TOUR-063: a held bike tour or run names its profile in its row, never a hike grade', () => {
renderRow({ ...PENDING, activity: 'bike', routingProfile: 'road', grades: null, limit: null });
expect(screen.getByText('Road')).toBeInTheDocument();
expect(screen.queryByTestId('tour-grade')).toBeNull();
});
}); });
@@ -53,7 +53,7 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<WayFigures way={legVariantOf(card, 'current')} unit={unit} /> <WayFigures way={legVariantOf(card, 'current')} unit={unit} />
</span> </span>
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />} {grades && grades.highest !== null && <TourGrade card={card} />}
</span> </span>
{previous && ( {previous && (
<VariantDelta <VariantDelta
@@ -18,7 +18,6 @@ import CardHeader from '../parts/CardHeader';
import { CardSection } from '../parts/CardSection'; import { CardSection } from '../parts/CardSection';
import WayFigures from './TourFigureLine'; import WayFigures from './TourFigureLine';
import { TourRouteBody } from './TourRouteCard'; import { TourRouteBody } from './TourRouteCard';
import { TourGrade } from './TourRouteHead';
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel'; import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords'; import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
import { useLineHover, useTourRouteCard } from './useTourRouteCard'; import { useLineHover, useTourRouteCard } from './useTourRouteCard';
@@ -115,8 +114,6 @@ function WayOption({
{grades && grades.highest !== null && ( {grades && grades.highest !== null && (
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} /> <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
)} )}
{/* Ways of a ride or a run may differ by profile: each says its own. */}
{entry.card.routingProfile && <TourGrade card={entry.card} />}
</span> </span>
<WayFigures way={way} unit={unit} /> <WayFigures way={way} unit={unit} />
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />} {!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
@@ -1,23 +1,5 @@
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared'; import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import { import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
ArrowLeftRight,
ChartNoAxesColumn,
Circle,
CircleCheck,
CircleSlash,
Copy,
Footprints,
History,
Luggage,
MapPinMinus,
MapPinPlus,
Palette,
Repeat,
Route,
Snowflake,
TriangleAlert,
Waves,
} from 'lucide-react';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap'; import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes'; import type { EntityTarget, PackLook } from '../cardTypes';
@@ -113,21 +95,10 @@ export const TOUR_LOOK = {
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' }, countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' }, linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' }, unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
reverseTour: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.reverseTour' },
closeLoop: { Icon: Repeat, tone: 'info', labelKey: 'assistant.change.verb.closeLoop' },
copyTour: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.copyTour' },
setRouteColor: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setRouteColor' },
classifySki: { Icon: Snowflake, tone: 'info', labelKey: 'assistant.change.verb.classifySki' },
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
}, },
tools: {}, tools: {},
steps: { steps: {
propose_city_walk: { stepKey: 'assistant.step.planningWalk' }, propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
propose_tour: { stepKey: 'assistant.step.planningRoute' },
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' }, plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
plan_ski_route: { stepKey: 'assistant.step.planningRoute' }, plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
get_tour_plan: { stepKey: 'assistant.step.readingTour' }, get_tour_plan: { stepKey: 'assistant.step.readingTour' },
@@ -135,14 +106,6 @@ export const TOUR_LOOK = {
find_tours_for_day: { stepKey: 'assistant.step.readingTours' }, find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' }, search_tour_pois: { stepKey: 'assistant.step.tourPois' },
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' }, get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
get_tour_trail_details: { stepKey: 'assistant.step.readingTour' },
propose_round_hike: { stepKey: 'assistant.step.findingRounds' },
propose_hike_leg_way: { stepKey: 'assistant.step.findingWays' },
search_wanderer_trails: { stepKey: 'assistant.step.searchingWanderer' },
preview_wanderer_trail: { stepKey: 'assistant.step.readingTrail' },
check_tour_source: { stepKey: 'assistant.step.checkingSource' },
// P7: a route file attached in the chat, read by the server into its tracks.
read_tour_file: { stepKey: 'assistant.step.readingTourFile' },
}, },
entities: { entities: {
tour: { tour: {
@@ -29,7 +29,6 @@ import {
sacAt, sacAt,
sacPaint, sacPaint,
steepStretches, steepStretches,
takeWaitsKey,
wayGroups, wayGroups,
} from './tourRouteModel'; } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel'; import { sketchOf } from './tourRouteSketchModel';
@@ -137,33 +136,13 @@ describe('tourRouteModel', () => {
}); });
}); });
it('FE-AI-TOUR-009: "Plan this" takes a planned way of every type, a city walk, a whole paddle or ski plan', () => { it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
expect(canTake(HIKE)).toBe(true); expect(canTake(HIKE)).toBe(true);
expect(canTake({ ...HIKE, routed: false })).toBe(false); expect(canTake({ ...HIKE, routed: false })).toBe(false);
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true); expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
expect(canTake({ ...HIKE, activity: 'bike', routingProfile: 'gravel' })).toBe(true);
expect(canTake({ ...HIKE, activity: 'run', routingProfile: 'trail', routed: false })).toBe(false);
expect(canTake(SAVED)).toBe(false); expect(canTake(SAVED)).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true); expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan' })).toBe(true);
// A plan with a leg that could not be planned is no tour yet; a hike is never a plan.
const failed = [{ kind: 'legs_failed' as const, at: null, count: 1 }];
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan', warnings: failed })).toBe(false);
expect(canTake({ ...HIKE, source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false); expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
// Another way for a leg of a saved hike changes that hike; it is not created anew.
expect(canTake({ ...HIKE, placeId: 12 })).toBe(false);
});
it("FE-AI-TOUR-060: a run waits for the runner's pace; a bike tour, a hike and a run with a pace wait for nothing", () => {
expect(takeWaitsKey({ ...HIKE, activity: 'run', routingProfile: 'running' })).toBe('assistant.card.tour.needsPace');
expect(takeWaitsKey({ ...HIKE, activity: 'run', paceSecondsPerKm: 330 })).toBeNull();
expect(takeWaitsKey({ ...HIKE, activity: 'bike' })).toBeNull();
expect(takeWaitsKey(HIKE)).toBeNull();
// A ride's router time is its riding time; a run's time is at its pace.
expect(timeLabelKey({ activity: 'bike', minutesBasis: 'router' })).toBe('tours.bike.timeEstimated');
expect(timeLabelKey({ activity: 'run', minutesBasis: 'pace' })).toBe('tours.run.timeEstimated');
expect(timeLabelKey({ activity: 'hike', minutesBasis: 'router' })).toBe('tours.scope.routed');
}); });
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => { it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
@@ -226,31 +226,14 @@ export function routePoints(card: TourRouteCard): [number, number][] {
} }
/** /**
* Whether "Plan this" can take the proposal: a city walk, a hike, a bike * Whether "Plan this" can take the proposal: a hike whose way was planned,
* tour or a run whose way was planned, or a paddle or ski plan whose every * or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
* leg was planned. A saved tour, a held change and the other way of a
* saved hike's leg (named by its place, changed with update_tour rather
* than created anew) cannot.
*/ */
export function canTake(card: TourRouteCard): boolean { export function canTake(card: TourRouteCard): boolean {
if (card.source === 'plan') { if (card.source !== 'proposal') return false;
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed'); return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
return (card.activity === 'kayak' || card.activity === 'ski') && card.routed && whole;
}
if (card.source !== 'proposal' || card.placeId !== null) return false;
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
} }
/** The proposals on land "Plan this" takes once their way is planned. */
const LAND_PLANS: ReadonlySet<TourRouteCard['activity']> = new Set(['hike', 'bike', 'run']);
/** Why "Plan this" waits on a card it can take: a run is saved with the runner's pace only. Null when nothing is missing. */
export function takeWaitsKey(card: TourRouteCard): string | null {
return card.activity === 'run' && card.paceSecondsPerKm == null ? NEEDS_PACE.labelKey : null;
}
const NEEDS_PACE = { labelKey: 'assistant.card.tour.needsPace' } as const;
/** A share as words take it, "40 %". */ /** A share as words take it, "40 %". */
export function percentText(share: number): string { export function percentText(share: number): string {
return `${Math.round(share * 100)} %`; return `${Math.round(share * 100)} %`;
@@ -92,25 +92,10 @@ const TIME_WORDS = {
planned: { labelKey: 'tours.scope.planned' }, planned: { labelKey: 'tours.scope.planned' },
stations: { labelKey: 'tours.scope.plannedWithStays' }, stations: { labelKey: 'tours.scope.plannedWithStays' },
piste: { labelKey: 'tours.ski.time' }, piste: { labelKey: 'tours.ski.time' },
pace: { labelKey: 'tours.run.timeEstimated' },
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>; } as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
/** What each bike and run profile is called, as the Tours tab names it. */ /** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
export const PROFILE_WORDS = {
touring: { labelKey: 'tours.bike.profile.touring' },
road: { labelKey: 'tours.bike.profile.road' },
gravel: { labelKey: 'tours.bike.profile.gravel' },
mtb: { labelKey: 'tours.bike.profile.mtb' },
running: { labelKey: 'tours.run.profile.running' },
trail: { labelKey: 'tours.run.profile.trail' },
} as const satisfies Record<NonNullable<TourRouteCard['routingProfile']>, { labelKey: string }>;
/**
* The name of a route's time: by how it was reckoned (a bike tour's router
* time is its riding time), else a paddle's own, else a walking time.
*/
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string { export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
if (card.minutesBasis === 'router' && card.activity === 'bike') return 'tours.bike.timeEstimated';
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey; if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated'; return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
} }
@@ -11,7 +11,7 @@ import { hasUndecided } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext'; import { useAssistantView } from '../../assistantViewContext';
import { useChangeHover } from '../../useChangeHover'; import { useChangeHover } from '../../useChangeHover';
import { tourTarget } from './tourLook'; import { tourTarget } from './tourLook';
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel'; import { canTake, routePoints } from './tourRouteModel';
import { takeInThread, takeOfProposal } from './tourTakeModel'; import { takeInThread, takeOfProposal } from './tourTakeModel';
/** /**
@@ -104,8 +104,7 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
} }
/** /**
* "Plan this" on a proposed tour (a hike, a bike tour, a run, a city walk) * "Plan this" on a proposed hike or city walk: one tap adds the tour. The
* or a paddle or ski plan: one tap adds the tour. The
* server holds the tour it creates as a change, without a model turn, and * server holds the tour it creates as a change, without a model turn, and
* the tap decides it at once, since tapping "Plan this" already is the * the tap decides it at once, since tapping "Plan this" already is the
* decision; the assistant then answers to what was added. Once the tour is * decision; the assistant then answers to what was added. Once the tour is
@@ -149,10 +148,9 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
}, []); }, []);
const visible = canTake(card) && !!trip && can('place_edit', trip); const visible = canTake(card) && !!trip && can('place_edit', trip);
let reasonKey: string | null; let reasonKey: string | null = null;
if (offline) reasonKey = 'assistant.error.offline'; if (offline) reasonKey = 'assistant.error.offline';
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst'; else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
else reasonKey = takeWaitsKey(card);
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy'; const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
const take = useCallback(async () => { const take = useCallback(async () => {
@@ -1,4 +1,4 @@
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared'; import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { type DragEvent, useState } from 'react'; import { type DragEvent, useState } from 'react';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast'; import { useToast } from '../shared/Toast';
/** What the booking import reads and the route files taken in as tours (the server checks again), and how big one file may be. */ /** What the booking import reads (the server checks again), and how big one file may be. */
const ATTACH_EXTENSIONS: readonly string[] = [ const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf', '.pdf',
'.eml', '.eml',
@@ -16,7 +16,6 @@ const ATTACH_EXTENSIONS: readonly string[] = [
'.htm', '.htm',
'.txt', '.txt',
...LLM_PHOTO_EXTENSIONS, ...LLM_PHOTO_EXTENSIONS,
...ASSISTANT_TOUR_FILE_EXTENSIONS,
]; ];
const MAX_ATTACH_BYTES = 10 * 1024 * 1024; const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
@@ -30,7 +29,7 @@ function hasFiles(e: DragEvent): boolean {
} }
/** /**
* Hands a booking file or a route file (GPX, KML, KMZ) to the chat, from the paperclip, a paste or a drop on * Hands a booking file to the chat, from the paperclip, a paste or a drop on
* the window. One file at a time, refused here with a toast when it is not * the window. One file at a time, refused here with a toast when it is not
* one the import reads or too big, so a wrong file never costs a round trip. * one the import reads or too big, so a wrong file never costs a round trip.
* The drop handlers go on the whole chat; `dragging` lights its drop zone. * The drop handlers go on the whole chat; `dragging` lights its drop zone.
@@ -1,102 +0,0 @@
/**
* FE-AI-HOME-001 to FE-AI-HOME-004
*
* The home chat's shared hooks: its key and name, the way into one trip's own
* chat with the question waiting there (a question already typed there
* stays), the way in from the pages outside a trip, and the questions the
* empty home chat offers.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { HOME_SUGGESTIONS } from './assistantModel';
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
import { useAssistantSuggestions } from './useAssistantSuggestions';
const INITIAL = useAssistantStore.getState();
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
});
function Home({ switchTo }: { switchTo: (trip: { id: number; title: string }) => void }) {
const home = useAssistantHome(switchTo);
return (
<div>
<p>
chat {home.chat.id} {home.chat.title}
</p>
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
ask
</button>
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, null)}>
go
</button>
</div>
);
}
function Launcher() {
const { available, open } = useAssistantHomeLauncher();
return available ? (
<button type="button" onClick={open}>
open
</button>
) : null;
}
function Suggestions() {
const { suggestions, canEdit } = useAssistantSuggestions(0);
return (
<ul data-can-edit={String(canEdit)}>
{suggestions.map((s) => (
<li key={s.id}>{s.prompt}</li>
))}
</ul>
);
}
describe('useAssistantHome', () => {
it('FE-AI-HOME-001: names the home chat and keeps it under its own key', () => {
render(<Home switchTo={vi.fn()} />);
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
});
it("FE-AI-HOME-002: goes on in a trip's chat with the question waiting, and never over one typed there", () => {
const switchTo = vi.fn();
render(<Home switchTo={switchTo} />);
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
expect(switchTo).toHaveBeenCalledWith({ id: 9, title: 'Lisbon' });
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
useAssistantStore.getState().setDraft(9, 'My own words');
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('My own words');
fireEvent.click(screen.getByRole('button', { name: 'go' }));
expect(switchTo).toHaveBeenCalledTimes(3);
});
});
describe('useAssistantHomeLauncher', () => {
it('FE-AI-HOME-003: offers the way in only while the assistant can run, and opens the window', () => {
const { rerender } = render(<Launcher />);
expect(screen.queryByRole('button', { name: 'open' })).not.toBeInTheDocument();
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
rerender(<Launcher />);
fireEvent.click(screen.getByRole('button', { name: 'open' }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
});
});
describe('the empty home chat', () => {
it('FE-AI-HOME-004: offers questions across the trips, all of them, and holds nothing back', () => {
render(<Suggestions />);
const items = screen.getAllByRole('listitem').map((item) => item.textContent);
expect(items).toHaveLength(HOME_SUGGESTIONS.length);
expect(items).toContain('Which trip is next, and what is still open?');
expect(items).toContain('How many countries have I visited?');
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
});
});
@@ -1,44 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import type { AssistantChatTrip } from './assistantViewContext';
/**
* The home chat, the assistant outside any one trip, as the desktop window
* and the phone screen both show it: the key its conversations are kept
* under, the name its head carries, and the way into one trip's own chat.
* That way leaves the question the home chat handed over waiting in the
* trip's composer, ready to send, and then lets the view switch (the window
* shows that trip's chat in place, the phone opens the trip with its chat).
*/
export function useAssistantHome(switchTo: (trip: AssistantChatTrip) => void) {
const { t } = useTranslation();
const setDraft = useAssistantStore((s) => s.setDraft);
const openTripChat = useCallback(
(trip: AssistantChatTrip, question: string | null) => {
// A question typed there already stays; the handed-over one only fills an empty composer.
const typed = useAssistantStore.getState().byTrip[trip.id]?.draft ?? '';
if (question && !typed.trim()) setDraft(trip.id, question);
switchTo(trip);
},
[setDraft, switchTo]
);
const title = t('assistant.home.title');
const chat = useMemo((): AssistantChatTrip => ({ id: HOME_CHAT_KEY, title }), [title]);
return { chat, openTripChat };
}
/**
* The way into the home chat on the pages outside a trip (the phone's
* dashboard head and the dock's More list): offered only while the assistant
* can run for this user, like every other way in.
*/
export function useAssistantHomeLauncher() {
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.open);
return { available, open };
}
@@ -3,7 +3,6 @@ import { useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { chatOf, useAssistantStore } from '../../store/assistantStore'; import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useOpenTourStore } from '../../store/openTourStore';
import { useCanDo } from '../../store/permissionsStore'; import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
@@ -42,10 +41,6 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
* Adding opens the planner's own place form, filled in with the place and on * Adding opens the planner's own place form, filled in with the place and on
* the day in focus: the traveller sees everything about it and saves it as * the day in focus: the traveller sees everything about it and saves it as
* any other place, so nothing waits for the chat's open decisions. * any other place, so nothing waits for the chat's open decisions.
*
* P6: while a saved hike or city walk is open in the planner, "+ Tour" takes
* a place into it as a stop: held as a change in the chat like any other,
* so the stop is only saved once the traveller applies it.
*/ */
export function useAssistantPlaceStrip() { export function useAssistantPlaceStrip() {
const { tripId, onRevealMap } = useAssistantView(); const { tripId, onRevealMap } = useAssistantView();
@@ -65,8 +60,6 @@ export function useAssistantPlaceStrip() {
); );
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null); const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft); const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
const insertFoundPlace = useAssistantStore((s) => s.insertFoundPlace);
const openTour = useOpenTourStore((s) => (s.open?.tripId === tripId ? s.open : null));
const point = useCardPoint(); const point = useCardPoint();
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null; const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
@@ -92,14 +85,6 @@ export function useAssistantPlaceStrip() {
openPlaceDraft(trip.id, place, focusDay?.id ?? null); openPlaceDraft(trip.id, place, focusDay?.id ?? null);
}; };
/** The saved tour a place can be taken into as a stop, null while none is open or nothing can be added. */
const tour = canAdd ? openTour : null;
/** Holds the place as a stop of the open tour, to decide in the chat. */
const addToTour = (place: AssistantFoundPlace) => {
if (tour) void insertFoundPlace(tripId, place.ref, tour.placeId);
};
/** Shows the place on the map; on the phone the chat steps aside first. */ /** Shows the place on the map; on the phone the chat steps aside first. */
const show = point.show; const show = point.show;
@@ -111,5 +96,5 @@ export function useAssistantPlaceStrip() {
[hoverPoint] [hoverPoint]
); );
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour }; return { canAdd, categoryOf, distanceOf, open, show, hover };
} }
@@ -6,7 +6,7 @@ import { useJourneyStore } from '../../store/journeyStore';
import { useCanDo } from '../../store/permissionsStore'; import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { localIsoDate } from '../../utils/localDate'; import { localIsoDate } from '../../utils/localDate';
import { HOME_SUGGESTIONS, suggestionsFor, tripPhase } from './assistantModel'; import { suggestionsFor, tripPhase } from './assistantModel';
import { useAssistantFocus } from './useAssistantFocus'; import { useAssistantFocus } from './useAssistantFocus';
export interface ReadySuggestion { export interface ReadySuggestion {
@@ -21,9 +21,7 @@ export interface ReadySuggestion {
* (one picked from the dashboard) neither its dates nor its rights are at * (one picked from the dashboard) neither its dates nor its rights are at
* hand, so it gets the general questions and none that would change it. A * hand, so it gets the general questions and none that would change it. A
* journey's chat in TREK Studio gets none, since they all ask about a trip, * journey's chat in TREK Studio gets none, since they all ask about a trip,
* and its rights are the journey's role, as the Studio itself reads it. The * and its rights are the journey's role, as the Studio itself reads it.
* home chat outside any one trip gets its own questions across the user's
* trips; what it may change the server decides, so nothing is held back here.
*/ */
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } { export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,11 +35,9 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
const journeyRole = useJourneyStore((s) => const journeyRole = useJourneyStore((s) =>
scope.kind === 'journey' && s.current?.id === scope.journeyId ? s.current.my_role : undefined scope.kind === 'journey' && s.current?.id === scope.journeyId ? s.current.my_role : undefined
); );
const home = scope.kind === 'home'; const canEdit = tripChat ? !!trip && can('place_edit', trip) : (journeyRole ?? 'owner') !== 'viewer';
const canEdit = tripChat ? !!trip && can('place_edit', trip) : home || (journeyRole ?? 'owner') !== 'viewer';
const suggestions = useMemo((): ReadySuggestion[] => { const suggestions = useMemo((): ReadySuggestion[] => {
if (home) return HOME_SUGGESTIONS.map((s) => ({ id: s.id, prompt: t(s.labelKey) }));
if (!tripChat) return []; if (!tripChat) return [];
const phase = trip const phase = trip
? tripPhase({ ? tripPhase({
@@ -57,7 +53,7 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
place: kind === 'place' ? label : null, place: kind === 'place' ? label : null,
day: kind === 'day' ? label : null, day: kind === 'day' ? label : null,
}).map((s) => ({ id: s.id, prompt: t(s.labelKey, s.params) })); }).map((s) => ({ id: s.id, prompt: t(s.labelKey, s.params) }));
}, [home, tripChat, trip, hasPlaces, canEdit, kind, label, t]); }, [tripChat, trip, hasPlaces, canEdit, kind, label, t]);
return { suggestions, canEdit }; return { suggestions, canEdit };
} }
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027 // FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-021
// Plyr needs a real media pipeline, so the player is stubbed here. // Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({ vi.mock('./VideoPlayer', () => ({
@@ -17,7 +17,6 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, fireEvent } from '../../../tests/helpers/render'; import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store'; import { resetAllStores } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import PhotoLightbox from './PhotoLightbox'; import PhotoLightbox from './PhotoLightbox';
const samplePhotos = [ const samplePhotos = [
@@ -248,82 +247,4 @@ describe('PhotoLightbox', () => {
expect(img.style.transform).toContain('scale(2)'); expect(img.style.transform).toContain('scale(2)');
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
}); });
it('FE-COMP-LIGHTBOX-022: is a labelled modal dialog', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Show photos" />);
const dialog = screen.getByRole('dialog', { name: 'Show photos' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
it('FE-COMP-LIGHTBOX-023: takes the focus when it opens and gives it back to the opener when it closes', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
try {
const { unmount } = render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} label="Gallery" />);
expect(screen.getByRole('dialog', { name: 'Gallery' })).toHaveFocus();
unmount();
expect(opener).toHaveFocus();
} finally {
opener.remove();
}
});
it('FE-COMP-LIGHTBOX-024: names its arrows', () => {
render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next photo' }));
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Previous photo' }));
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
describe('right to left', () => {
beforeEach(() => {
// The direction follows the language, as in the app, where the provider also sets <html dir>.
useSettingsStore.setState((state) => ({ settings: { ...state.settings, language: 'ar' } }));
});
afterEach(() => {
document.documentElement.dir = '';
});
it('FE-COMP-LIGHTBOX-025: the arrow keys follow the reading direction', () => {
render(<PhotoLightbox photos={samplePhotos} onClose={vi.fn()} />);
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-026: a swipe to the right turns to the next photo', () => {
const { container } = render(<PhotoLightbox photos={samplePhotos} startIndex={1} onClose={vi.fn()} />);
const surface = container.firstElementChild as HTMLElement;
fireEvent.touchStart(surface, { touches: [{ clientX: 100, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 200, clientY: 210 }] });
expect(screen.getByText('3 / 3')).toBeInTheDocument();
fireEvent.touchStart(surface, { touches: [{ clientX: 200, clientY: 200 }] });
fireEvent.touchEnd(surface, { changedTouches: [{ clientX: 100, clientY: 210 }] });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
});
it('FE-COMP-LIGHTBOX-027: a caption with a source page links to it in a new tab, one without stays text', () => {
render(
<PhotoLightbox
photos={[
{ id: 'a', src: '/a.jpg', caption: 'Alice, CC BY 4.0', captionHref: 'https://commons.wikimedia.org/wiki/File:A.jpg' },
{ id: 'b', src: '/b.jpg', caption: 'Google', captionHref: null },
]}
onClose={vi.fn()}
/>,
);
const link = screen.getByRole('link', { name: 'Alice, CC BY 4.0' });
expect(link).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:A.jpg');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
}); });
+17 -44
View File
@@ -3,14 +3,12 @@ import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
import VideoPlayer from './VideoPlayerLazy' import VideoPlayer from './VideoPlayerLazy'
import { usePhotoZoom } from './usePhotoZoom' import { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import { isRtlLanguage, useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
interface LightboxPhoto { interface LightboxPhoto {
id: string id: string
src: string src: string
caption?: string | null caption?: string | null
/** Where the caption's credit points to (a photo's source page), opened in a new tab. */
captionHref?: string | null
provider?: string provider?: string
asset_id?: string | null asset_id?: string | null
owner_id?: number | null owner_id?: number | null
@@ -21,8 +19,6 @@ interface Props {
photos: LightboxPhoto[] photos: LightboxPhoto[]
startIndex?: number startIndex?: number
onClose: () => void onClose: () => void
/** What the dialog is called for assistive tech. */
label?: string
} }
/** The round glass buttons of the top bar. */ /** The round glass buttons of the top bar. */
@@ -32,21 +28,10 @@ const ROUND_BTN: React.CSSProperties = {
color: '#fff', cursor: 'pointer', color: '#fff', cursor: 'pointer',
} }
export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }: Props) { export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
const [idx, setIdx] = useState(startIndex) const [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null) const touchStart = useRef<{ x: number; y: number } | null>(null)
const dialogRef = useRef<HTMLDivElement>(null) const { t } = useTranslation()
const { t, language } = useTranslation()
// Right to left, the next photo lies to the left: the arrow keys and swipes follow the reading direction.
// Taken from the language, not read off <html dir>, so it stays put across re-renders.
const rtl = isRtlLanguage(language)
// Focus moves into the dialog when it opens and back to what opened it when it closes.
useEffect(() => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
dialogRef.current?.focus()
return () => opener?.focus()
}, [])
const photo = photos[idx] const photo = photos[idx]
// Zoom starts over on every photo (#1484). // Zoom starts over on every photo (#1484).
@@ -61,12 +46,12 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') (rtl ? next : prev)() if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') (rtl ? prev : next)() if (e.key === 'ArrowRight') next()
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [prev, next, onClose, rtl]) }, [prev, next, onClose])
// A pinch, a double tap or a pan of an enlarged photo belongs to the zoom; only // A pinch, a double tap or a pan of an enlarged photo belongs to the zoom; only
// the rest is a swipe that turns the page or closes the lightbox. // the rest is a swipe that turns the page or closes the lightbox.
@@ -94,7 +79,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
} }
// horizontal swipe // horizontal swipe
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) { if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0 !== rtl) next() if (dx < 0) next()
else prev() else prev()
} }
touchStart.current = null touchStart.current = null
@@ -104,13 +89,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
return ( return (
<div <div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={{ style={{
outline: 'none',
position: 'fixed', inset: 0, zIndex: 10000, position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)', background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
@@ -165,15 +144,15 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Prev button — visible on hover (desktop), always visible (mobile) */} {/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && ( {hasPrev && (
<button type="button" onClick={prev} aria-label={t('journey.lightbox.previous')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{ <button type="button" onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineStart: 16, zIndex: 5, position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%', width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer', color: '#fff', cursor: 'pointer',
}}> }}>
<ChevronLeft size={22} className="rtl:rotate-180" /> <ChevronLeft size={22} />
</button> </button>
)} )}
@@ -200,33 +179,27 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Next button */} {/* Next button */}
{hasNext && ( {hasNext && (
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{ <button type="button" onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', insetInlineEnd: 16, zIndex: 5, position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%', width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer', color: '#fff', cursor: 'pointer',
}}> }}>
<ChevronRight size={22} className="rtl:rotate-180" /> <ChevronRight size={22} />
</button> </button>
)} )}
{/* Caption — bottom center overlay */} {/* Caption — bottom center overlay */}
{photo.caption && ( {photo.caption && (
<div style={{ position: 'absolute', bottom: 20, insetInline: 0, zIndex: 5, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}> <div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{ <p style={{
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5, maxWidth: '70%', textAlign: 'center', color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10, pointerEvents: 'auto', padding: '6px 14px', borderRadius: 10,
}}> }}>{photo.caption}</p>
{photo.captionHref ? (
<a href={photo.captionHref} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }} className="hover:underline">
{photo.caption}
</a>
) : photo.caption}
</p>
</div> </div>
)} )}
</div> </div>
@@ -15,6 +15,8 @@ import {
calculateAlternatives, calculateAlternatives,
avoidedClasses, avoidedClasses,
routeEngineFor, routeEngineFor,
sameRoad,
furthestFrom,
osrmLegSegment, osrmLegSegment,
} from './RouteCalculator' } from './RouteCalculator'
@@ -1202,6 +1204,37 @@ describe('a trip that avoids something', () => {
expect(plain.avoidance).toBeUndefined() expect(plain.avoidance).toBeUndefined()
}) })
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
// The shape of a leg bent by a via: every point of the plain road lies on it, so
// asked only one way round the two read as one.
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 }
expect(sameRoad(plainRoad, spur)).toBe(false)
expect(sameRoad(spur, plainRoad)).toBe(false)
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true)
})
it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
// exactly between those samples, 1.8 km from the nearest, on the very same road.
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
const sparse: [number, number][] = [
[50, 0],
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
[50, 10],
]
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
expect(sameRoad(a, b)).toBe(true)
const off = furthestFrom([[50.02, 5]], dense)
expect(off?.index).toBe(0)
expect(off?.km).toBeCloseTo(2.22, 1)
})
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => { it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
server.use( server.use(
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })), http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
+120 -1
View File
@@ -5,7 +5,7 @@ import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units' import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter' import { countRoute } from './routeUsageCounter'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute' import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared' import type { RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the // FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use // project-osrm.org demo is car-only (it ignores the profile in the URL). Use
@@ -803,6 +803,41 @@ export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'dura
*/ */
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
/**
* How far two lines may stray from each other and still be the same road, in kilometres.
*
* Parallel carriageways, a different slip road onto the same motorway and the noise
* between two engines' idea of where a road is all sit well under this. A way somebody
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
*/
const SAME_ROAD_KM = 0.5
/**
* Two lines are the same road.
*
* Length and duration used to decide this, and stopped being enough when a second engine
* started answering. Valhalla prices roads differently: measured over twelve European
* legs its distances ran 1.8 % above OSRM's, so on a 470 km leg the same road comes back
* thirteen kilometres apart and a 200 m tolerance calls it a discovery. Two identical
* blue lines on the map is exactly what that looks like. Duration is the worse of the
* two to lean on: over the same twelve legs it ranged from 14.7 % under OSRM's to 13.2 %
* over, with the sign set by the region rather than by the road.
*
* The geometry does not care which engine drew it, so it decides whenever there is one.
* The numbers stay as the fallback for a line with no shape to compare.
*
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
* whole of `b` and then turns off to a point and back passed as `b`, because every point
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
* the check behind a choice both have to tell it from the plain road.
*/
export function sameRoad(a: RoadLine, b: RoadLine): boolean {
const there = furthestFrom(b.coordinates, a.coordinates)
const back = furthestFrom(a.coordinates, b.coordinates)
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
}
/** /**
* Hosts that have already answered "I do not do exclude", by base URL. * Hosts that have already answered "I do not do exclude", by base URL.
@@ -1068,3 +1103,87 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
const furthest = furthestFrom(line, reference) const furthest = furthestFrom(line, reference)
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
} }
/**
* The same point, plus how far off it actually is.
*
* The search itself stays in squared degrees with a cosine on the longitude: it only has
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*
* `index` is where on `line` the point sits, so points taken off one line can be put back
* in the order that line drives through them.
*/
export function furthestFrom(
line: [number, number][],
reference: [number, number][],
): { index: number; lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200))
let bestIndex = -1
let bestNeighbour: [number, number] | null = null
let bestDist = -1
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const near = nearestOn(line[i], reference, step)
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
}
if (bestIndex < 0) return null
const [lat, lng] = line[bestIndex]
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
return { index: bestIndex, lat, lng, km }
}
/**
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
* degrees and the spot on the line it was measured to.
*
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
* on the very same road sat up to half a sample apart from it, which on a thousand
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
*/
function nearestOn(
point: [number, number],
reference: [number, number][],
step: number,
): { d: number; at: [number, number] } {
const cos = Math.cos((point[0] * Math.PI) / 180)
const squared = (p: [number, number]): number => {
const dLat = point[0] - p[0]
const dLng = (point[1] - p[1]) * cos
return dLat * dLat + dLng * dLng
}
let nearestIndex = 0
let nearest = Infinity
for (let j = 0; j < reference.length; j += step) {
const d = squared(reference[j])
if (d < nearest) { nearest = d; nearestIndex = j }
}
let at = reference[nearestIndex]
const last = Math.min(reference.length - 1, nearestIndex + step)
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
const d = squared(on)
if (d < nearest) { nearest = d; at = on }
}
return { d: nearest, at }
}
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
function closestOnSegment(
p: [number, number],
a: [number, number],
b: [number, number],
cos: number,
): [number, number] {
const dx = (b[1] - a[1]) * cos
const dy = b[0] - a[0]
const length = dx * dx + dy * dy
if (length === 0) return a
const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length))
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
@@ -110,8 +110,8 @@ describe('PlaceDetailsColumn', () => {
}) })
}) })
it('FE-PDC-004: renders author and licence under the Commons picture in play', async () => { it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn({ selectedImageUrl: COMMONS_PHOTO.url }) renderColumn()
const credit = await screen.findByText('Alice') const credit = await screen.findByText('Alice')
// Links to the file description page, where the full terms are. // Links to the file description page, where the full terms are.
@@ -122,11 +122,9 @@ describe('PlaceDetailsColumn', () => {
it('FE-PDC-005: names the source when a picture has no author, instead of inventing one', async () => { it('FE-PDC-005: names the source when a picture has no author, instead of inventing one', async () => {
placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null, facts: [] }) placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null, facts: [] })
renderColumn({ selectedImageUrl: GOOGLE_PHOTO.url }) renderColumn()
expect(await screen.findByText('Google')).toBeInTheDocument() expect(await screen.findByText('Google')).toBeInTheDocument()
// The tile's name carries the credit too, after a colon.
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toBeInTheDocument()
}) })
it('FE-PDC-006: picking a picture reports its proxy URL', async () => { it('FE-PDC-006: picking a picture reports its proxy URL', async () => {
@@ -613,24 +611,17 @@ describe('PlaceDetailsColumn — picture layout', () => {
withPhotos(3) withPhotos(3)
const { onPickImage } = renderColumn() const { onPickImage } = renderColumn()
// Nothing is picked and there is no own picture to remove, so 'No picture'
// stays away and the first photo is credited.
await screen.findByText('Author 0') await screen.findByText('Author 0')
expect(screen.queryByText('places.image.none')).not.toBeInTheDocument()
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ }) const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.mouseEnter(tiles[2]) fireEvent.mouseEnter(tiles[2])
expect(screen.getByText('Author 2')).toBeInTheDocument() expect(screen.getByText('Author 2')).toBeInTheDocument()
expect(screen.queryByText('Author 0')).not.toBeInTheDocument()
expect(onPickImage).not.toHaveBeenCalled() expect(onPickImage).not.toHaveBeenCalled()
fireEvent.mouseLeave(tiles[2])
expect(screen.queryByText('Author 2')).not.toBeInTheDocument()
expect(screen.getByText('Author 0')).toBeInTheDocument()
}) })
it('FE-PDC-037: credits the picture in play, licence included', async () => { it('FE-PDC-037: credits the picture in play, licence included', async () => {
withPhotos(2) withPhotos(2)
renderColumn({ selectedImageUrl: '/api/maps/place-photo/p0/bytes' }) renderColumn()
expect(await screen.findByText('Author 0')).toBeInTheDocument() expect(await screen.findByText('Author 0')).toBeInTheDocument()
expect(screen.getByText('Author 0').closest('a')).toHaveAttribute( expect(screen.getByText('Author 0').closest('a')).toHaveAttribute(
@@ -681,12 +672,9 @@ describe('PlaceDetailsColumn — dialog variant', () => {
const tile = await screen.findByRole('button', { name: /places.details.pickImage/ }) const tile = await screen.findByRole('button', { name: /places.details.pickImage/ })
expect(tile).not.toHaveAttribute('title') expect(tile).not.toHaveAttribute('title')
// Hovering the tile moves the credit line to it.
fireEvent.mouseEnter(tile)
const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a[href*="creativecommons"]' }) const licence = screen.getByText('CC BY-SA 4.0', { selector: 'a[href*="creativecommons"]' })
const line = licence.parentElement?.parentElement as HTMLElement expect(licence.closest('p')).toHaveTextContent('Alice')
expect(line).toHaveTextContent('Alice') expect(licence.closest('p')?.textContent).not.toContain('·')
expect(line.textContent).not.toContain('·')
fireEvent.click(tile) fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url) expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url)
@@ -800,41 +788,3 @@ describe('PlaceDetailsColumn — dialog variant', () => {
expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument() expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument()
}) })
}) })
/**
* FE-PDC-050..051 — the editor's own pictures beside the sources' ones.
*/
describe('PlaceDetailsColumn — the place\'s own pictures', () => {
const own = (over: Record<string, unknown> = {}) => ({
currentUrl: '/uploads/places/own.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
})
it('FE-PDC-050: offers the upload and the current picture even while nothing is selected', () => {
const mine = own()
renderColumn({ selection: null, image: { own: mine, chosen: { kind: 'current' } } })
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument()
const current = screen.getByRole('button', { name: 'places.image.current' })
expect(current).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(current)
expect(mine.onRemove).toHaveBeenCalledTimes(1)
})
it('FE-PDC-051: the sources\' photos join the own ones once they arrive, the choice following the image prop', async () => {
const { onPickImage } = renderColumn({ image: { own: own(), chosen: { kind: 'url', url: COMMONS_PHOTO.url } } })
const tile = await screen.findByRole('button', { name: 'places.details.pickImage: Alice' })
expect(tile).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'places.image.current' })).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(tile)
expect(onPickImage).toHaveBeenCalledWith(null)
})
})
@@ -4,6 +4,7 @@ import {
ArrowRight, ArrowRight,
Bike, Bike,
Building2, Building2,
Check,
ChefHat, ChefHat,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@@ -32,22 +33,22 @@ import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts' import { LABEL } from '../shared/dialogParts'
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts' import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker'
import { chosenImage, offersPictures, type ChosenImage } from './placeImageChoice'
import { cacheKeyFor, readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment'
export { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './placeEnrichment' /** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
lat: number
lng: number
name: string
/** The picked search result, so the server can skip its own details lookup. */
details?: Record<string, unknown>
}
interface PlaceDetailsColumnProps { interface PlaceDetailsColumnProps {
selection: PlaceDetailsSelection | null selection: PlaceDetailsSelection | null
/** Currently chosen hero image, so the picked tile can show as picked. */ /** Currently chosen hero image, so the picked tile can show as picked. */
selectedImageUrl?: string | null selectedImageUrl?: string
onPickImage: (url: string | null) => void onPickImage: (url: string | null) => void
/**
* The editor's own pictures (upload, the current one, attached images) and
* which tile is chosen; without it only the sources' pictures are offered.
*/
image?: { own: PlaceImageOwn; chosen: ChosenImage }
onAdoptDescription: (text: string) => void onAdoptDescription: (text: string) => void
/** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */ /** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */
onHours?: (hours: PlaceHours) => void onHours?: (hours: PlaceHours) => void
@@ -74,6 +75,47 @@ interface PlaceDetailsColumnProps {
t: TranslationFn t: TranslationFn
} }
/**
* Module-level cache plus sessionStorage, same shape as usePlaceDetails in
* PlaceInspector. Clicking back and forth between two search results must not
* pay for the provider fan-out twice.
*/
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>()
/** Test seam: the module-level cache otherwise leaks between cases. */
export function __clearEnrichmentCacheForTests(): void {
enrichmentCache.clear()
}
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
try {
const raw = sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined
} catch {
return undefined
}
}
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch {
/* private mode / quota — the in-memory cache still helps for this session */
}
}
/**
* Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so
* without it the tab that was open while the fix shipped keeps replaying the
* answer the fix was about — and reports it as still broken.
*/
const ENRICH_CACHE_V = 4
function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`
}
type LoadState = 'idle' | 'loading' | 'ready' | 'error' type LoadState = 'idle' | 'loading' | 'ready' | 'error'
/** /**
@@ -119,7 +161,6 @@ export default function PlaceDetailsColumn({
locale = 'en-US', locale = 'en-US',
fluid = false, fluid = false,
variant = 'sheet', variant = 'sheet',
image,
t, t,
}: PlaceDetailsColumnProps): React.ReactElement { }: PlaceDetailsColumnProps): React.ReactElement {
const dialog = variant === 'dialog' const dialog = variant === 'dialog'
@@ -142,8 +183,9 @@ export default function PlaceDetailsColumn({
return return
} }
const cached = readCachedEnrichment(selection, language) const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
if (cached) { if (cached) {
enrichmentCache.set(selectionKey, cached)
setData(cached) setData(cached)
setState('ready') setState('ready')
return return
@@ -175,7 +217,8 @@ export default function PlaceDetailsColumn({
) )
.then((result) => { .then((result) => {
if (controller.signal.aborted) return if (controller.signal.aborted) return
storeEnrichment(selection, language, result) enrichmentCache.set(selectionKey, result)
writeSession(selectionKey, result)
setData(result) setData(result)
setState('ready') setState('ready')
}) })
@@ -236,16 +279,6 @@ export default function PlaceDetailsColumn({
)} )}
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}> <div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
{/* The place's picture first: its own ones are there whatever the sources answer. */}
<PictureBlock
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
chosen={image?.chosen ?? chosenImage(selectedImageUrl, null, null)}
onPickImage={onPickImage}
own={image?.own}
dialog={dialog}
t={t}
/>
{/* Nothing picked yet, which is a state rather than a fault: the mascot {/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. The dialog says the waits with an idle look instead of a sad one. The dialog says the
same in a quiet card, and the column keeps its width either way. */} same in a quiet card, and the column keeps its width either way. */}
@@ -298,6 +331,7 @@ export default function PlaceDetailsColumn({
{selection && state === 'ready' && !data?.disabled && !isEmpty && ( {selection && state === 'ready' && !data?.disabled && !isEmpty && (
<> <>
<PhotoStrip photos={data?.photos ?? []} selectedImageUrl={selectedImageUrl} onPickImage={onPickImage} dialog={dialog} t={t} />
<RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} /> <RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} />
<OpeningHoursBlock <OpeningHoursBlock
hours={data?.hours ?? null} hours={data?.hours ?? null}
@@ -355,31 +389,119 @@ export default function PlaceDetailsColumn({
) )
} }
/** The picker under its label; nothing while there is no picture to offer. */ /** The name to put under a picture. Google gives no author, so it gets its own name. */
function PictureBlock({ function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source)
}
/**
* The picture grid.
*
* A grid rather than one large image: this sits beside a form in a dialog, and
* a picture that takes half the column pushes the facts and the description out
* of sight. Three columns at 320px gives ~96px tiles, which is enough to tell
* the pictures apart while leaving the rest of the column visible.
*
* Only the picture in play is credited in full — crediting all of them at once
* cost two lines each and drowned the column, and the licence obligation
* attaches to the one that gets used. Every tile still carries the full credit
* as its tooltip and links to its source page.
*/
function PhotoStrip({
photos, photos,
chosen, selectedImageUrl,
onPickImage, onPickImage,
own, dialog = false,
dialog,
t, t,
}: { }: {
photos: PlacePhotoCandidate[] photos: PlacePhotoCandidate[]
chosen: ChosenImage selectedImageUrl?: string
onPickImage: (url: string | null) => void onPickImage: (url: string | null) => void
own?: PlaceImageOwn dialog?: boolean
dialog: boolean
t: TranslationFn t: TranslationFn
}): React.ReactElement | null { }): React.ReactElement | null {
if (!offersPictures(photos, own)) return null const [hovered, setHovered] = useState<string | null>(null)
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} /> if (photos.length === 0) return null
const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0]
const body = (
<>
<div className="grid grid-cols-3 gap-1.5">
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={selectedImageUrl === photo.url}
onPick={onPickImage}
onHover={setHovered}
dialog={dialog}
t={t}
/>
))}
</div>
<p className={dialog ? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint truncate'}>
<PhotoCredit photo={shown} dialog={dialog} />
</p>
</>
)
return ( return (
<Block label={t('places.details.pickImage')} dialog={dialog}> <Block label={t('places.details.pickImage')} dialog={dialog}>
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{picker}</div> : picker} {dialog ? <div className={`${COLUMN_CARD} p-2`}>{body}</div> : body}
</Block> </Block>
) )
} }
function PhotoTile({
photo,
selected,
onPick,
onHover,
dialog = false,
t,
}: {
photo: PlacePhotoCandidate
selected: boolean
onPick: (url: string | null) => void
onHover: (url: string | null) => void
/** On a white card, and without the native tooltip: hovering a tile already moves the credit line under the grid to it. */
dialog?: boolean
t: TranslationFn
}): React.ReactElement {
const credit = creditOf(photo)
return (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(photo.url)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(photo.url)}
onBlur={() => onHover(null)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')} — ${credit}`}
title={dialog ? undefined : `${credit}${photo.license ? ` · ${photo.license}` : ''}`}
className={`group relative block w-full aspect-square overflow-hidden rounded-lg transition-shadow ${
selected
? `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`
: 'ring-1 ring-edge hover:ring-content-muted'
}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && (
<span className="absolute top-1 end-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="w-2.5 h-2.5 text-accent-on" />
</span>
)}
</button>
)
}
/** /**
* Who the description came from, as the reader would name them. * Who the description came from, as the reader would name them.
* *
@@ -411,6 +533,75 @@ function descriptionSourceLabel(
} }
} }
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
return 'Wikimedia Commons'
}
/**
* Author and licence for the picture currently in play.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author does not satisfy those terms. When a source hands
* us no author (Google), we say where it came from rather than inventing one.
* Rendered inline — the container supplies the colour and the truncation.
*/
function PhotoCredit({ photo, dialog = false }: { photo: PlacePhotoCandidate; dialog?: boolean }): React.ReactElement {
const credit = creditOf(photo)
if (dialog) {
// Two pieces side by side with room between them, rather than joined by a dot.
return (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
)
}
return (
<>
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
{photo.license && (
<>
{' · '}
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</>
)}
</>
)
}
/** /**
* Shown when the free sources found nothing and no Google key is configured. * Shown when the free sources found nothing and no Google key is configured.
@@ -1,65 +0,0 @@
// FE-PLANNER-PLFORMMEDIA-001 to FE-PLANNER-PLFORMMEDIA-003: the files waiting for a new
// place, and the place's picture in the form where no details column stands beside it.
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia';
import type { PlaceImageOwn } from './PlaceImagePicker';
vi.mock('../../api/authUrl', () => ({ fetchImageAsBlob: vi.fn(async () => 'blob:x') }));
const t = ((key: string) => key) as never;
describe('PendingFiles', () => {
it('FE-PLANNER-PLFORMMEDIA-001: the attach button opens the input, which adds the files', () => {
const fileRef = createRef<HTMLInputElement>();
const onAdd = vi.fn();
render(<PendingFiles files={[]} fileRef={fileRef} onAdd={onAdd} onRemove={vi.fn()} t={t} />);
expect(screen.queryByTestId('pending-files')).not.toBeInTheDocument();
const openPicker = vi.spyOn(fileRef.current as HTMLInputElement, 'click');
fireEvent.click(screen.getByRole('button', { name: 'files.attach' }));
expect(openPicker).toHaveBeenCalledTimes(1);
fireEvent.change(fileRef.current as HTMLInputElement, { target: { files: [new File(['x'], 'a.pdf')] } });
expect(onAdd).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLFORMMEDIA-002: each waiting file is listed by name and removed by its index', () => {
const onRemove = vi.fn();
render(
<PendingFiles
files={[new File(['x'], 'ticket.pdf'), new File(['y'], 'map.png')]}
fileRef={createRef<HTMLInputElement>()}
onAdd={vi.fn()}
onRemove={onRemove}
t={t}
/>
);
expect(screen.getByTestId('pending-files')).toHaveTextContent('ticket.pdf');
expect(screen.getByText('map.png')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1]);
expect(onRemove).toHaveBeenCalledWith(1);
});
});
describe('PlaceImageSection', () => {
it("FE-PLANNER-PLFORMMEDIA-003: the picker sits under the field's label with the place's own pictures", () => {
const own: PlaceImageOwn = {
currentUrl: '/uploads/places/c.jpg',
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
};
const onPickUrl = vi.fn();
render(<PlaceImageSection image={{ own, chosen: { kind: 'none' } }} onPickUrl={onPickUrl} t={t} />);
expect(screen.getByText('places.details.pickImage')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'places.uploadImage' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith('/uploads/places/c.jpg');
});
});
@@ -1,84 +0,0 @@
import { Paperclip, X } from 'lucide-react';
import React from 'react';
import type { TranslationFn } from '../../types';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { LABEL } from '../shared/dialogParts';
import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker';
import { COLUMN_CARD, WHITE_BUTTON } from './placeDialogParts';
import { offersPictures, type ChosenImage } from './placeImageChoice';
/** Files chosen before the place exists: they are uploaded once the save has given it an id. */
export function PendingFiles({
files,
fileRef,
onAdd,
onRemove,
t,
}: {
files: File[];
fileRef: React.RefObject<HTMLInputElement | null>;
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemove: (index: number) => void;
t: TranslationFn;
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button type="button" onClick={() => fileRef.current?.click()} className={WHITE_BUTTON} style={fs(13, 'body')}>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div
key={idx}
className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5"
style={fs(12, 'body')}
>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button
type="button"
onClick={() => onRemove(idx)}
aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger"
>
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
);
}
/**
* The place's picture in the form itself, where no details column stands
* beside it (place details switched off): the same picker, with the place's
* own pictures and an upload, under the field's label.
*/
export function PlaceImageSection({
image,
onPickUrl,
t,
}: {
image: { own: PlaceImageOwn; chosen: ChosenImage };
onPickUrl: (url: string | null) => void;
t: TranslationFn;
}) {
if (!offersPictures([], image.own)) return null;
return (
<div className="min-w-0">
<div className={LABEL}>{t('places.details.pickImage')}</div>
<div className={`${COLUMN_CARD} p-2`}>
<PlaceImagePicker photos={[]} chosen={image.chosen} onPickUrl={onPickUrl} own={image.own} dialog t={t} />
</div>
</div>
);
}
@@ -1,8 +1,8 @@
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-011: the place form's openings and save // FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-010: the place form's openings and save
// payload, as the desktop dialog and the phone sheet both build them. // payload, as the desktop dialog and the phone sheet both build them.
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Assignment, Place, TripFile } from '../../types'; import type { Assignment, Place } from '../../types';
import { import {
DEFAULT_FORM, DEFAULT_FORM,
duplicateName, duplicateName,
@@ -155,18 +155,4 @@ describe('placeFormPayload', () => {
const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] })); const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] }));
expect(payload.at).toEqual([48.86, null]); expect(payload.at).toEqual([48.86, null]);
}); });
it('FE-PLANNER-PLFORMH-011: a waiting picture travels as the file, or as the attached file id, or not at all', () => {
expect(placeFormPayload(form, [], null)._pendingImage).toBeUndefined();
const picture = new File(['x'], 'front.png');
expect(
placeFormPayload(form, [], null, undefined, { kind: 'upload', file: picture, preview: 'blob:x' })._pendingImage
).toBe(picture);
const attached = { id: 12, mime_type: 'image/png' } as TripFile;
expect(placeFormPayload(form, [], null, undefined, { kind: 'attached', file: attached })._pendingImage).toEqual({
fileId: 12,
});
// A dropped picture is the field's null, and it is saved as such.
expect(placeFormPayload({ ...form, image_url: null }, [], null).image_url).toBeNull();
});
}); });
@@ -1,5 +1,4 @@
import type { Assignment, Place, RoadtripStopType } from '@trek/shared' import type { Assignment, Place, RoadtripStopType } from '@trek/shared'
import { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice'
import type { PlaceDetailsSelection } from './PlaceDetailsColumn' import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
export interface PlaceFormData { export interface PlaceFormData {
@@ -28,8 +27,7 @@ export interface PlaceFormData {
// Hero image picked from the detail column. Optional and absent from // Hero image picked from the detail column. Optional and absent from
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets // DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
// it, and places.service already writes image_url through on create/update. // it, and places.service already writes image_url through on create/update.
// Null drops the place's picture on save; undefined keeps it. image_url?: string
image_url?: string | null
// Day-specific note on the in-context assignment (#2163). Only hydrated when // Day-specific note on the in-context assignment (#2163). Only hydrated when
// the form opened with an assignment in context; both forms drop it from the // the form opened with an assignment in context; both forms drop it from the
// submit payload when unchanged, and useTripPlanner strips it off the place // submit payload when unchanged, and useTripPlanner strips it off the place
@@ -310,8 +308,6 @@ export interface PlaceFormPayload extends Omit<PlaceFormData, 'lat' | 'lng' | 'c
category_id: string | null category_id: string | null
/** Files chosen before the place existed: uploaded once the save has given it an id. */ /** Files chosen before the place existed: uploaded once the save has given it an id. */
_pendingFiles?: File[] _pendingFiles?: File[]
/** A picture waiting to become the place's own: a file from the device, or an attached file's id. */
_pendingImage?: PendingImagePayload
} }
/** /**
@@ -326,7 +322,6 @@ export function placeFormPayload<E extends object = object>(
pendingFiles: File[], pendingFiles: File[],
assignment: Pick<Assignment, 'notes'> | null | undefined, assignment: Pick<Assignment, 'notes'> | null | undefined,
extra?: (lat: number | null, lng: number | null) => E, extra?: (lat: number | null, lng: number | null) => E,
pendingImage: PendingPlaceImage | null = null,
): PlaceFormPayload & E { ): PlaceFormPayload & E {
const lat = form.lat ? Number.parseFloat(form.lat) : null const lat = form.lat ? Number.parseFloat(form.lat) : null
const lng = form.lng ? Number.parseFloat(form.lng) : null const lng = form.lng ? Number.parseFloat(form.lng) : null
@@ -337,7 +332,6 @@ export function placeFormPayload<E extends object = object>(
category_id: form.category_id || null, category_id: form.category_id || null,
...(extra ? extra(lat, lng) : {}), ...(extra ? extra(lat, lng) : {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
_pendingImage: pendingImagePayload(pendingImage),
} as PlaceFormPayload & E } as PlaceFormPayload & E
if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) { if (!assignment || (form.assignment_notes ?? '') === (assignment.notes ?? '')) {
delete payload.assignment_notes delete payload.assignment_notes
@@ -1,89 +0,0 @@
// FE-PLANNER-PLACEFORM-125 to FE-PLANNER-PLACEFORM-127: the place's picture in the desktop
// dialog. Nothing is sent before the save; the waiting picture travels with it.
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace, buildTripFile } from '../../../tests/helpers/factories';
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import PlaceFormModal from './PlaceFormModal';
vi.mock('../shared/CustomTimePicker', async () => ({
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
}));
vi.mock('../../api/authUrl', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/authUrl')>()),
fetchImageAsBlob: vi.fn(async () => 'blob:attached'),
}));
const imageInput = () => document.querySelector('input[type="file"][accept^="image"]') as HTMLInputElement;
beforeEach(() => {
resetAllStores();
seedPlaceFormStores();
// Without the details column the picker sits in the form itself.
seedStore(useAuthStore, { placesEnrichEnabled: false });
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:form-preview');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PlaceFormModal, the picture', () => {
it('FE-PLANNER-PLACEFORM-125: a new place gets a picture from the device, uploaded once the save made it', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render(<PlaceFormModal {...placeFormProps} onSave={onSave} />);
expect(screen.getByText('Pick a picture')).toBeInTheDocument();
const picture = new File(['x'], 'front.png', { type: 'image/png' });
fireEvent.change(imageInput(), { target: { files: [picture] } });
const tile = await screen.findByRole('button', { name: 'Upload image', pressed: true });
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:form-preview');
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Louvre', _pendingImage: picture }));
});
it('FE-PLANNER-PLACEFORM-126: an edited place keeps its picture as chosen, or takes an attached image by its id', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 61, name: 'Gallery', image_url: '/uploads/places/61.jpg' });
useTripStore.setState({
files: [buildTripFile({ id: 88, place_id: 61, mime_type: 'image/jpeg', original_name: 'hall.jpg' })],
} as never);
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: 'hall.jpg' }));
expect(screen.getByRole('button', { name: 'hall.jpg' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _pendingImage: { fileId: 88 } }));
});
it('FE-PLANNER-PLACEFORM-127: removing the picture saves the place without one, and nothing waits', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
const place = buildPlace({ id: 62, name: 'Bridge', image_url: '/uploads/places/62.jpg' });
render(<PlaceFormModal {...placeFormProps} place={place} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: 'Remove image' }));
expect(screen.getByRole('button', { name: 'Current picture' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('No picture')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const payload = onSave.mock.calls[0][0];
expect(payload.image_url).toBeNull();
expect(payload._pendingImage).toBeUndefined();
});
});
@@ -913,7 +913,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => { it('FE-PLANNER-PLACEFORM-031: pending files list shows file names after adding', async () => {
render(<PlaceFormModal {...defaultProps} />); render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement; const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeTruthy(); expect(fileInput).toBeTruthy();
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }); const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
@@ -925,7 +925,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => { it('FE-PLANNER-PLACEFORM-032: removing a pending file removes it from the list', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />); render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement; const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' }); const file = new File(['x'], 'remove-me.jpg', { type: 'image/jpeg' });
fireEvent.change(fileInput, { target: { files: [file] } }); fireEvent.change(fileInput, { target: { files: [file] } });
@@ -1470,7 +1470,7 @@ describe('PlaceFormModal', () => {
it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => { it('FE-PLANNER-PLACEFORM-057: the Attach button opens the hidden file input', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />); render(<PlaceFormModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][multiple]') as HTMLInputElement; const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await user.click(screen.getByRole('button', { name: /Attach/i })); await user.click(screen.getByRole('button', { name: /Attach/i }));
expect(clickSpy).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled();
@@ -2422,7 +2422,7 @@ describe('PlaceFormModal on the dialog frame', () => {
it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => { it('FE-PLANNER-PLACEFORM-103: a pending file is removed by its named button', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<PlaceFormModal {...defaultProps} />); render(<PlaceFormModal {...defaultProps} />);
fireEvent.change(document.querySelector('input[type="file"][multiple]') as HTMLInputElement, { fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] }, target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] },
}); });
await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' })); await user.click(within(screen.getByTestId('pending-files')).getByRole('button', { name: 'Delete' }));
@@ -8,11 +8,9 @@ import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import CollectionPicker from '../Collections/CollectionPicker' import CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn from './PlaceDetailsColumn' import PlaceDetailsColumn from './PlaceDetailsColumn'
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia'
import { usePlaceImageField } from './usePlaceImageField'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip' import { Tooltip } from '../shared/Tooltip'
import { Search, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react' import { Search, Paperclip, X, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { PlaceContactFields } from './PlaceContactFields' import { PlaceContactFields } from './PlaceContactFields'
import { weekFromPeriods } from './placeHours' import { weekFromPeriods } from './placeHours'
@@ -43,6 +41,7 @@ import {
} from '../shared/DialogShell' } from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts' import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
import { SoftPill, tintOf } from './planParts' import { SoftPill, tintOf } from './planParts'
import { WHITE_BUTTON } from './placeDialogParts'
import { TimeSection } from './PlaceFormTimeSection' import { TimeSection } from './PlaceFormTimeSection'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and // The submit payload mirrors the form, but lat/lng are parsed to numbers and
@@ -122,11 +121,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const { const {
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving, form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage, duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage, adoptDescription, warnIfDuplicate,
} = usePlaceForm({ t, toast, canUploadFiles }) } = usePlaceForm({ t, toast, canUploadFiles })
const image = usePlaceImageField({
place, imageUrl: form.image_url, pendingImage, canUploadFiles, stageUpload, stageAttached, removeImage,
})
// The index the picked place came from, as its row named it, shown as a pill // The index the picked place came from, as its row named it, shown as a pill
// in the head band. Null for a place typed by hand or taken from the saved list. // in the head band. Null for a place typed by hand or taken from the saved list.
const [pickedSource, setPickedSource] = useState<string | null>(null) const [pickedSource, setPickedSource] = useState<string | null>(null)
@@ -362,7 +358,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
...(serviceStop ...(serviceStop
? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement } ? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement }
: {}), : {}),
}), pendingImage) }))
const saved = await onSave(payload) const saved = await onSave(payload)
// Open the Costs editor for the saved place when the user asked to // Open the Costs editor for the saved place when the user asked to
// create/edit its linked expense — gated on an id, so a create that the // create/edit its linked expense — gated on an id, so a create that the
@@ -446,7 +442,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handleRemoveFile: removeFile, handleRemoveFile: removeFile,
handlePaste, handlePaste,
pickImage, pickImage,
image,
adoptDescription, adoptDescription,
hasTimeError, hasTimeError,
handleSubmit, handleSubmit,
@@ -611,7 +606,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
selection={detailsSelection} selection={detailsSelection}
selectedImageUrl={form.image_url} selectedImageUrl={form.image_url}
onPickImage={S.pickImage} onPickImage={S.pickImage}
image={S.image}
onAdoptDescription={S.adoptDescription} onAdoptDescription={S.adoptDescription}
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))} onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
hasDescription={!!form.description.trim()} hasDescription={!!form.description.trim()}
@@ -885,7 +879,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
maxLength={2000} maxLength={2000}
/> />
{!showDetails && <PlaceImageSection image={S.image} onPickUrl={S.pickImage} t={t} />}
{/* The link and the files side by side, as on a booking. */} {/* The link and the files side by side, as on a booking. */}
<div className={canUploadFiles ? GRID_2 : ''}> <div className={canUploadFiles ? GRID_2 : ''}>
<EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}> <EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}>
@@ -1124,3 +1117,44 @@ function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
</DialogSection> </DialogSection>
) )
} }
/** Files chosen before the place exists: they are uploaded once the save has given it an id. */
function PendingFiles({ files, fileRef, onAdd, onRemove, t }: {
files: File[]
fileRef: React.RefObject<HTMLInputElement | null>
onAdd: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemove: (index: number) => void
t: Translate
}) {
return (
<div className="min-w-0">
<div className={LABEL}>{t('files.title')}</div>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onAdd} />
<button
type="button"
onClick={() => fileRef.current?.click()}
className={WHITE_BUTTON}
style={fs(13, 'body')}
>
<Paperclip size={14} aria-hidden="true" />
{t('files.attach')}
</button>
{files.length > 0 && (
<div className="mt-1.5 flex flex-col gap-1" data-testid="pending-files">
{files.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-[8px] border border-edge-faint bg-surface-card px-2.5 py-1.5" style={fs(12, 'body')}>
<Paperclip size={11} className="flex-none text-content-faint" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-content-secondary">{file.name}</span>
<Tooltip label={t('common.delete')}>
<button type="button" onClick={() => onRemove(idx)} aria-label={t('common.delete')}
className="flex flex-none text-content-faint transition-colors hover:text-danger">
<X size={12} />
</button>
</Tooltip>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,258 +0,0 @@
// FE-PLANNER-PLIMGPICK-001 to FE-PLANNER-PLIMGPICK-016: the editor's picture picker, the one
// place a place's picture is chosen: the upload tile, the current picture, the images
// attached to the place, the sources' photos, the credit line and "Remove image".
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { PlacePhotoCandidate } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { placePictureRepo } from '../../repo/placePictureRepo';
import { normalizeImageFile } from '../../utils/convertHeic';
import PlaceImagePicker, { PhotoCredit, type PlaceImageOwn } from './PlaceImagePicker';
import type { ChosenImage } from './placeImageChoice';
vi.mock('../../repo/placePictureRepo', () => ({ placePictureRepo: { attachedImage: vi.fn() } }));
vi.mock('../../utils/convertHeic', () => ({
normalizeImageFile: vi.fn(async (file: File) => new File([file], `normalized-${file.name}`, { type: 'image/jpeg' })),
}));
/** Echo the key back, which keeps the assertions readable. */
const t = ((key: string) => key) as never;
const CURRENT = '/uploads/places/current.jpg';
const COMMONS: PlacePhotoCandidate = {
key: 'w~p0',
url: '/api/maps/place-photo/w~p0/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/',
sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'w~p1',
url: '/api/maps/place-photo/w~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const ATTACHED = buildTripFile({ id: 41, original_name: 'facade.png', mime_type: 'image/png', url: '/api/files/41' });
function own(over: Partial<PlaceImageOwn> = {}): PlaceImageOwn {
return {
currentUrl: null,
attached: [],
pendingPreview: null,
canUpload: true,
onUpload: vi.fn(),
onPickAttached: vi.fn(),
onRemove: vi.fn(),
...over,
};
}
function renderPicker(
props: Partial<{
photos: PlacePhotoCandidate[];
chosen: ChosenImage;
own: PlaceImageOwn;
dialog: boolean;
}> = {}
) {
const onPickUrl = vi.fn();
const utils = render(
<PlaceImagePicker
photos={props.photos ?? []}
chosen={props.chosen ?? { kind: 'none' }}
onPickUrl={onPickUrl}
own={props.own}
dialog={props.dialog}
t={t}
/>
);
return { ...utils, onPickUrl };
}
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
beforeEach(() => {
vi.mocked(placePictureRepo.attachedImage).mockReset();
vi.mocked(placePictureRepo.attachedImage).mockResolvedValue('blob:attached');
vi.mocked(normalizeImageFile).mockClear();
});
describe('PlaceImagePicker', () => {
it('FE-PLANNER-PLIMGPICK-001: renders nothing while there is no picture to offer and no upload', () => {
const bare = renderPicker();
expect(bare.container).toBeEmptyDOMElement();
bare.unmount();
const readOnly = renderPicker({ own: own({ canUpload: false }) });
expect(readOnly.container).toBeEmptyDOMElement();
});
it('FE-PLANNER-PLIMGPICK-002: the upload tile opens the file picker and hands the converted file over', async () => {
const mine = own();
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'false');
const input = fileInput();
expect(input).toHaveAttribute('accept', 'image/*,.heic,.heif');
const openPicker = vi.spyOn(input, 'click');
fireEvent.click(tile);
expect(openPicker).toHaveBeenCalledTimes(1);
const picture = new File(['x'], 'photo.heic', { type: 'image/heic' });
fireEvent.change(input, { target: { files: [picture] } });
await waitFor(() => expect(mine.onUpload).toHaveBeenCalledTimes(1));
expect(normalizeImageFile).toHaveBeenCalledWith(picture);
expect(vi.mocked(mine.onUpload).mock.calls[0][0].name).toBe('normalized-photo.heic');
await waitFor(() => expect(tile).not.toBeDisabled());
});
it('FE-PLANNER-PLIMGPICK-003: an emptied file choice hands nothing over', () => {
const mine = own();
renderPicker({ own: mine });
fireEvent.change(fileInput(), { target: { files: [] } });
expect(normalizeImageFile).not.toHaveBeenCalled();
expect(mine.onUpload).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLIMGPICK-004: a picture waiting for the save shows as its preview, chosen and credited as yours', () => {
renderPicker({ own: own({ pendingPreview: 'blob:preview' }), chosen: { kind: 'upload' } });
const tile = screen.getByRole('button', { name: 'places.uploadImage' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:preview');
expect(screen.getByText('places.image.own')).toBeInTheDocument();
// A waiting upload can be dropped again.
expect(screen.getByRole('button', { name: 'places.removeImage' })).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-005: the current picture is chosen by default, and a second tap removes it', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, chosen: { kind: 'current' } });
const tile = screen.getByRole('button', { name: 'places.image.current' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(tile.querySelector('img')).toHaveAttribute('src', CURRENT);
expect(screen.getByText('places.image.current')).toBeInTheDocument();
fireEvent.click(tile);
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-006: once dropped, the current picture is taken back with a tap', () => {
const mine = own({ currentUrl: CURRENT });
const { onPickUrl } = renderPicker({ own: mine, chosen: { kind: 'none' } });
// The line under the grid says what the save will do.
expect(screen.getByText('places.image.none')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'places.image.current' }));
expect(onPickUrl).toHaveBeenCalledWith(CURRENT);
expect(mine.onRemove).not.toHaveBeenCalled();
});
it("FE-PLANNER-PLIMGPICK-007: a current picture that is one of the sources' photos is not shown twice", () => {
renderPicker({ photos: [COMMONS], own: own({ currentUrl: COMMONS.url }), chosen: { kind: 'current' } });
expect(screen.queryByRole('button', { name: 'places.image.current' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /places.details.pickImage/ })).toHaveLength(1);
});
it('FE-PLANNER-PLIMGPICK-008: an attached image loads through the file route and is picked with a tap', async () => {
const mine = own({ attached: [ATTACHED] });
renderPicker({ own: mine });
const tile = screen.getByRole('button', { name: 'facade.png' });
await waitFor(() => expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:attached'));
expect(placePictureRepo.attachedImage).toHaveBeenCalledWith('/api/files/41');
fireEvent.click(tile);
expect(mine.onPickAttached).toHaveBeenCalledWith(ATTACHED);
});
it('FE-PLANNER-PLIMGPICK-009: a chosen attached image is ticked and named under the grid; one that fails to load stays blank', async () => {
vi.mocked(placePictureRepo.attachedImage).mockRejectedValue(new Error('gone'));
renderPicker({ own: own({ attached: [ATTACHED] }), chosen: { kind: 'attached', fileId: 41 } });
const tile = screen.getByRole('button', { name: 'facade.png' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('facade.png')).toBeInTheDocument();
await waitFor(() => expect(placePictureRepo.attachedImage).toHaveBeenCalled());
expect(tile.querySelector('img')).toBeNull();
// Focusing it names it as well, and the name stays once it is left.
fireEvent.focus(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
fireEvent.blur(tile);
expect(screen.getByText('facade.png')).toBeInTheDocument();
});
it("FE-PLANNER-PLIMGPICK-010: a source's photo is picked with a tap and let go with a second", () => {
const first = renderPicker({ photos: [COMMONS, GOOGLE] });
fireEvent.click(screen.getByRole('button', { name: 'places.details.pickImage: Alice' }));
expect(first.onPickUrl).toHaveBeenCalledWith(COMMONS.url);
first.unmount();
const chosen = renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: GOOGLE.url } });
const tile = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
expect(tile).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(tile);
expect(chosen.onPickUrl).toHaveBeenCalledWith(null);
});
it('FE-PLANNER-PLIMGPICK-011: the chosen photo is credited with its author and licence, each linked', () => {
renderPicker({ photos: [COMMONS, GOOGLE], chosen: { kind: 'url', url: COMMONS.url } });
expect(screen.getByText('Alice').closest('a')).toHaveAttribute('href', COMMONS.sourceUrl);
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toHaveAttribute('href', COMMONS.licenseUrl);
// Hovering another tile moves the credit line to it, leaving moves it back.
const google = screen.getByRole('button', { name: 'places.details.pickImage: Google' });
fireEvent.mouseEnter(google);
expect(screen.getByText('Google')).toBeInTheDocument();
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
fireEvent.mouseLeave(google);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-012: without the own pictures the first photo is credited and nothing can be removed', () => {
renderPicker({ photos: [COMMONS], chosen: { kind: 'url', url: '/not/among/them.jpg' } });
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'places.removeImage' })).not.toBeInTheDocument();
});
it('FE-PLANNER-PLIMGPICK-013: "Remove image" leaves the place without a picture', () => {
const mine = own({ currentUrl: CURRENT });
renderPicker({ own: mine, photos: [COMMONS], chosen: { kind: 'url', url: COMMONS.url } });
fireEvent.click(screen.getByRole('button', { name: 'places.removeImage' }));
expect(mine.onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLIMGPICK-014: the sheet gives each photo a native tooltip, the dialog does not', () => {
const sheet = renderPicker({ photos: [COMMONS, GOOGLE] });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).toHaveAttribute(
'title',
'Alice, CC BY-SA 4.0'
);
expect(screen.getByRole('button', { name: 'places.details.pickImage: Google' })).toHaveAttribute('title', 'Google');
sheet.unmount();
renderPicker({ photos: [COMMONS], dialog: true });
expect(screen.getByRole('button', { name: 'places.details.pickImage: Alice' })).not.toHaveAttribute('title');
});
});
describe('PhotoCredit', () => {
it('FE-PLANNER-PLIMGPICK-015: a photo without links is still credited in plain text', () => {
render(<PhotoCredit photo={{ ...COMMONS, sourceUrl: null, licenseUrl: null }} />);
expect(screen.getByText('Alice').closest('a')).toBeNull();
expect(screen.getByText('CC BY-SA 4.0').closest('a')).toBeNull();
});
it('FE-PLANNER-PLIMGPICK-016: a photo without a licence shows only its credit', () => {
const { container } = render(<PhotoCredit photo={GOOGLE} />);
expect(container).toHaveTextContent(/^Google$/);
});
});
@@ -1,374 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import { Check, ImageOff, ImagePlus, Loader2 } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { placePictureRepo } from '../../repo/placePictureRepo';
import type { TranslationFn, TripFile } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
import { Tooltip } from '../shared/Tooltip';
import { offersPictures, type ChosenImage } from './placeImageChoice';
import { creditOf } from './placePhotoCredit';
/** The place's own pictures beside the sources' ones: what it has, what it can get, what waits for the save. */
export interface PlaceImageOwn {
/** The picture the place has now; null on a new place or one without. */
currentUrl: string | null;
/** Images attached to the place that can stand as its picture. */
attached: TripFile[];
/** The picture from the device that waits for the save, as a preview. */
pendingPreview: string | null;
/** False where the user may not upload files; the upload tile is then left out. */
canUpload: boolean;
onUpload: (file: File) => void;
onPickAttached: (file: TripFile) => void;
onRemove: () => void;
}
interface PlaceImagePickerProps {
/** What the sources know, as found for the place; empty while they load or when there are none. */
photos: PlacePhotoCandidate[];
chosen: ChosenImage;
onPickUrl: (url: string | null) => void;
/** Left out where only the sources' pictures are offered. */
own?: PlaceImageOwn;
/** On a white card, without native tooltips: hovering moves the credit line instead. */
dialog?: boolean;
t: TranslationFn;
}
const TILE = 'group relative block aspect-square w-full overflow-hidden rounded-lg transition-shadow';
function ring(selected: boolean, dialog: boolean): string {
if (selected)
return `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`;
return 'ring-1 ring-edge hover:ring-content-muted';
}
function Picked(): React.ReactElement {
return (
<span className="absolute end-1 top-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="h-2.5 w-2.5 text-accent-on" />
</span>
);
}
/** What the credit line under the grid says: a source's photo with its author and licence, or the place's own. */
type Credit = { kind: 'photo'; photo: PlacePhotoCandidate } | { kind: 'own'; label: string } | null;
/**
* The one place a place's picture is chosen, in the editor: a tile to upload
* one from the device, the picture it has now, the images attached to it and
* the pictures its sources know. The chosen tile is ringed and ticked; a
* second tap on a source's photo lets go of it, and "Remove picture" leaves
* the place without one. Nothing is sent before the form is saved.
*/
export default function PlaceImagePicker({
photos,
chosen,
onPickUrl,
own,
dialog = false,
t,
}: PlaceImagePickerProps): React.ReactElement | null {
const [hovered, setHovered] = useState<Credit>(null);
const current = own?.currentUrl && !photos.some((p) => p.url === own.currentUrl) ? own.currentUrl : null;
const attached = own?.attached ?? [];
if (!offersPictures(photos, own)) return null;
const shown = hovered ?? creditFor(chosen, photos, attached, own, t);
const removable = !!own && chosen.kind !== 'none';
return (
<>
<div className="grid grid-cols-3 gap-1.5">
{own?.canUpload && (
<UploadTile
own={own}
selected={chosen.kind === 'upload'}
dialog={dialog}
onHover={(on) => setHovered(on ? { kind: 'own', label: t('places.image.own') } : null)}
t={t}
/>
)}
{current && (
<button
type="button"
onClick={() => (chosen.kind === 'current' ? own?.onRemove() : onPickUrl(current))}
aria-pressed={chosen.kind === 'current'}
aria-label={t('places.image.current')}
onMouseEnter={() => setHovered({ kind: 'own', label: t('places.image.current') })}
onMouseLeave={() => setHovered(null)}
className={`${TILE} ${ring(chosen.kind === 'current', dialog)}`}
>
<img src={current} alt="" className="h-full w-full object-cover" />
{chosen.kind === 'current' && <Picked />}
</button>
)}
{attached.map((file) => (
<AttachedTile
key={file.id}
file={file}
selected={chosen.kind === 'attached' && chosen.fileId === file.id}
onPick={() => own?.onPickAttached(file)}
onHover={(on) => setHovered(on ? { kind: 'own', label: file.original_name } : null)}
dialog={dialog}
/>
))}
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={chosen.kind === 'url' && chosen.url === photo.url}
onPick={onPickUrl}
onHover={(on) => setHovered(on ? { kind: 'photo', photo } : null)}
dialog={dialog}
t={t}
/>
))}
</div>
{(shown || removable) && (
<CreditLine shown={shown} onRemove={removable ? own?.onRemove : undefined} dialog={dialog} t={t} />
)}
</>
);
}
/** The tile that takes a picture from the device, or shows the one waiting for the save. */
function UploadTile({
own,
selected,
dialog,
onHover,
t,
}: {
own: PlaceImageOwn;
selected: boolean;
dialog: boolean;
onHover: (on: boolean) => void;
t: TranslationFn;
}): React.ReactElement {
const fileRef = useRef<HTMLInputElement>(null);
const [reading, setReading] = useState(false);
const pick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setReading(true);
try {
own.onUpload(await normalizeImageFile(file));
} finally {
setReading(false);
}
};
const preview = own.pendingPreview;
return (
<>
<Tooltip label={t('places.uploadImage')} placement="top">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={reading}
aria-pressed={selected}
aria-label={t('places.uploadImage')}
className={`${TILE} ${ring(selected, dialog)} ${preview ? '' : 'border border-dashed border-edge bg-surface-card'}`}
onMouseEnter={() => onHover(!!preview)}
onMouseLeave={() => onHover(false)}
>
{preview ? (
<>
<img src={preview} alt="" className="h-full w-full object-cover" />
{selected && <Picked />}
</>
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1 px-1 text-center text-caption leading-tight text-content-muted group-hover:text-content">
{reading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" strokeWidth={2} />
)}
{t('places.uploadImage')}
</span>
)}
</button>
</Tooltip>
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" className="hidden" onChange={(e) => void pick(e)} />
</>
);
}
/** Who the chosen picture is from, and the way to leave the place without one. */
function CreditLine({
shown,
onRemove,
dialog,
t,
}: {
shown: Credit;
onRemove?: () => void;
dialog: boolean;
t: TranslationFn;
}): React.ReactElement {
return (
<div
className={`${dialog ? 'mt-2' : 'mt-1.5'} flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint`}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{shown?.kind === 'photo' && <PhotoCredit photo={shown.photo} />}
{shown?.kind === 'own' && <span className="min-w-0 truncate">{shown.label}</span>}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="inline-flex flex-none items-center gap-1 rounded-md px-1.5 py-0.5 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
<ImageOff className="h-3 w-3" />
{t('places.removeImage')}
</button>
)}
</div>
);
}
/** The credit line for the chosen tile: a source's photo, the place's own picture, or nothing to say. */
function creditFor(
chosen: ChosenImage,
photos: PlacePhotoCandidate[],
attached: TripFile[],
own: PlaceImageOwn | undefined,
t: TranslationFn
): Credit {
if (chosen.kind === 'url') {
const photo = photos.find((p) => p.url === chosen.url);
if (photo) return { kind: 'photo', photo };
}
if (chosen.kind === 'current') return { kind: 'own', label: t('places.image.current') };
if (chosen.kind === 'upload') return { kind: 'own', label: t('places.image.own') };
if (chosen.kind === 'attached') {
const file = attached.find((f) => f.id === chosen.fileId);
if (file) return { kind: 'own', label: file.original_name };
}
if (chosen.kind === 'none' && own?.currentUrl) return { kind: 'own', label: t('places.image.none') };
return photos[0] ? { kind: 'photo', photo: photos[0] } : null;
}
function PhotoTile({
photo,
selected,
onPick,
onHover,
dialog,
t,
}: {
photo: PlacePhotoCandidate;
selected: boolean;
onPick: (url: string | null) => void;
onHover: (on: boolean) => void;
dialog: boolean;
t: TranslationFn;
}): React.ReactElement {
const credit = creditOf(photo);
return (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')}: ${credit}`}
title={dialog ? undefined : photo.license ? `${credit}, ${photo.license}` : credit}
className={`${TILE} ${ring(selected, dialog)}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && <Picked />}
</button>
);
}
/** An image attached to the place, loaded through the authenticated file route. */
function AttachedTile({
file,
selected,
onPick,
onHover,
dialog,
}: {
file: TripFile;
selected: boolean;
onPick: () => void;
onHover: (on: boolean) => void;
dialog: boolean;
}): React.ReactElement {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
let current = true;
placePictureRepo
.attachedImage(file.url)
.then((url) => {
if (current) setSrc(url);
})
.catch(() => {
if (current) setSrc(null);
});
return () => {
current = false;
};
}, [file.url]);
return (
<button
type="button"
onClick={onPick}
onMouseEnter={() => onHover(true)}
onMouseLeave={() => onHover(false)}
onFocus={() => onHover(true)}
onBlur={() => onHover(false)}
aria-pressed={selected}
aria-label={file.original_name}
className={`${TILE} bg-surface-card ${ring(selected, dialog)}`}
>
{src && <img src={src} alt="" className="h-full w-full object-cover" />}
{selected && <Picked />}
</button>
);
}
/**
* Author and licence of the source's photo in play, each linked to its page.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author does not satisfy those terms. When a source hands
* us no author (Google), we say where it came from rather than inventing one.
*/
export function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement {
const credit = creditOf(photo);
return (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
);
}
@@ -23,7 +23,6 @@ vi.mock('../../api/client', async (importOriginal) => {
mapsApi: { mapsApi: {
details: vi.fn().mockResolvedValue({ place: null }), details: vi.fn().mockResolvedValue({ place: null }),
placePhotoCredit: vi.fn().mockResolvedValue({ credit: null }), placePhotoCredit: vi.fn().mockResolvedValue({ credit: null }),
placeEnrichment: vi.fn().mockResolvedValue({ photos: [], facts: [], description: null }),
}, },
}; };
}); });
@@ -55,7 +54,6 @@ beforeAll(() => {
import PlaceInspector from './PlaceInspector'; import PlaceInspector from './PlaceInspector';
import { mapsApi } from '../../api/client'; import { mapsApi } from '../../api/client';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
// ── Shared fixtures ─────────────────────────────────────────────────────────── // ── Shared fixtures ───────────────────────────────────────────────────────────
@@ -102,8 +100,6 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } }); seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } });
vi.mocked(mapsApi.details).mockResolvedValue({ place: null }); vi.mocked(mapsApi.details).mockResolvedValue({ place: null });
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ photos: [], facts: [], description: null } as never);
__clearEnrichmentCacheForTests();
}); });
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
@@ -974,21 +970,18 @@ describe('PlaceInspector', () => {
} }
}); });
// ── The avatar opens the place's photos (#1136) ───────────────────────────── // ── Custom thumbnail upload (#1136) ──────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-049: the avatar opens the photos and offers no upload, the picture is changed in the editor', () => { it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => {
render(<PlaceInspector {...defaultProps} />); render(<PlaceInspector {...defaultProps} onUploadImage={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Show photos' })).toBeTruthy(); // The place carries no image yet, so the avatar offers "Upload image".
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull(); expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
}); });
it('FE-PLANNER-INSPECTOR-050: with no picture and nobody to ask for one the avatar stays a plain picture', () => { it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
render(<PlaceInspector {...defaultProps} />); render(<PlaceInspector {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Show photos' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
}); });
// ── Track colour (#776) ────────────────────────────────────────────────────── // ── Track colour (#776) ──────────────────────────────────────────────────────
@@ -1422,23 +1415,15 @@ describe('PlaceInspector', () => {
expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull(); expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull();
}); });
// ── The avatar's gallery (#1136) ──────────────────────────────────────────── // ── Custom thumbnail callbacks (#1136) ───────────────────────────────────────
it('FE-PLANNER-INSPECTOR-088: the custom image opens at once in the gallery, and the avatar has no remove control', async () => { it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const onUpdatePlace = vi.fn(); const onUpdatePlace = vi.fn();
const onUploadImage = vi.fn(async () => {});
const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' }); const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' });
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} />); render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/x.jpg');
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(onUpdatePlace).not.toHaveBeenCalled();
fireEvent.click(within(gallery).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Show photos' })).toBeNull());
}); });
it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => { it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => {
@@ -1560,28 +1545,13 @@ describe('PlaceInspector', () => {
} }
}); });
it("FE-PLANNER-INSPECTOR-089: the avatar asks the sources for the place's photos on click and shows them credited", async () => { it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ const onUploadImage = vi.fn(async () => {});
photos: [{ render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
key: 'gp~p0', url: '/api/maps/place-photo/gp~p0/bytes', attribution: 'Alice', license: 'CC BY 4.0', const input = document.querySelector('input[accept*="image"]') as HTMLInputElement;
licenseUrl: null, sourceUrl: 'https://commons.wikimedia.org/wiki/File:T.jpg', source: 'wikimedia', const file = new File(['x'], 'thumb.png', { type: 'image/png' });
}], fireEvent.change(input, { target: { files: [file] } });
facts: [], await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file));
description: null,
} as never);
render(<PlaceInspector {...defaultProps} />);
// Nothing is asked before the click: selecting a place is not worth the provider fan-out.
expect(mapsApi.placeEnrichment).not.toHaveBeenCalled();
expect(document.querySelector('input[type="file"][accept*="image"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = await screen.findByRole('dialog', { name: 'Show photos' });
// The query, then the signal the repo passes on (placePictureRepo.ts).
expect(vi.mocked(mapsApi.placeEnrichment).mock.calls[0]?.[0]).toMatchObject({ lat: place.lat, lng: place.lng, name: 'Eiffel Tower' });
expect(within(gallery).getByRole('img')).toHaveAttribute('src', '/api/maps/place-photo/gp~p0/bytes');
expect(within(gallery).getByRole('link', { name: 'Alice, CC BY 4.0' })).toHaveAttribute(
'href', 'https://commons.wikimedia.org/wiki/File:T.jpg',
);
}); });
it('FE-PLANNER-INSPECTOR-098: deselecting and reselecting a place survives a rerender', async () => { it('FE-PLANNER-INSPECTOR-098: deselecting and reselecting a place survives a rerender', async () => {
@@ -9,7 +9,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react' import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
import { hoursLines, periodsFromWeek } from './placeHours' import { hoursLines, periodsFromWeek } from './placeHours'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import PlacePhotoGallery from './PlacePhotoGallery' import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode' import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating' import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker' import TrackColorPicker from '../shared/TrackColorPicker'
@@ -186,6 +186,10 @@ interface PlaceInspectorProps {
tripMembers?: TripMember[] tripMembers?: TripMember[]
onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => void onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
/** Upload a custom thumbnail (#1136); enables the click-to-change avatar in trip mode. */
onUploadImage?: (placeId: number, file: File) => Promise<void>
/** Takes a picture already attached to the place as its image (#1242). */
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
/** Cast/clear the current user's star vote (#1435); enables the rating row. */ /** Cast/clear the current user's star vote (#1435); enables the rating row. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | void onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number leftWidth?: number
@@ -211,7 +215,7 @@ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null, place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking, assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment, onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate, files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onUploadImage, onImageFromFile, onRate,
leftWidth = 0, rightWidth = 0, clearMapPill = false, leftWidth = 0, rightWidth = 0, clearMapPill = false,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) { }: PlaceInspectorProps) {
@@ -388,6 +392,9 @@ export default function PlaceInspector({
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue} editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit} commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
onUpdatePlace={onUpdatePlace} onUpdatePlace={onUpdatePlace}
onUploadImage={mode === 'trip' && onUpdatePlace ? onUploadImage : undefined}
attachedImages={pickableImages(placeFiles)}
onImageFromFile={mode === 'trip' && onUpdatePlace ? onImageFromFile : undefined}
onClose={onClose} /> onClose={onClose} />
{/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */} {/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */}
@@ -591,17 +598,19 @@ interface InspectorHeadProps {
handleNameKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void handleNameKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
startNameEdit: () => void startNameEdit: () => void
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
onUploadImage?: (placeId: number, file: File) => Promise<void>
attachedImages: TripFile[]
onImageFromFile?: (placeId: number, fileId: number) => Promise<void>
onClose: () => void onClose: () => void
} }
/** /**
* The head band: the avatar with its open/closed ring and photo credit, the * The head band: the avatar with its open/closed ring and photo credit, the
* name (a double-click renames it), the address, and the facts as pills. The * name (a double-click renames it), the address, and the facts as pills.
* avatar opens the place's photos; its picture is changed in the editor.
*/ */
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef, function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) { nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
const { t, locale, language } = useTranslation() const { t, locale } = useTranslation()
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm' const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
const hasCoords = !!(place.lat && place.lng) const hasCoords = !!(place.lat && place.lng)
const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords
@@ -610,9 +619,13 @@ function InspectorHead({ place, category, openNow, phone, email, rating, ratingC
<div className="flex items-start gap-3.5"> <div className="flex items-start gap-3.5">
<div className="flex w-[57px] flex-none flex-col items-center"> <div className="flex w-[57px] flex-none flex-col items-center">
<div className={`rounded-full p-[2.5px] ${ring}`}> <div className={`rounded-full p-[2.5px] ${ring}`}>
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full"> {onUploadImage
<PlaceAvatar place={place} category={category} size={52} /> ? <PlaceAvatarUpload place={place} category={category} size={52}
</PlacePhotoGallery> onUpload={(file: File) => onUploadImage(place.id, file)}
onRemove={() => onUpdatePlace(place.id, { image_url: null })}
attachedImages={attachedImages}
onPickAttached={onImageFromFile ? (fileId: number) => onImageFromFile(place.id, fileId) : undefined} />
: <PlaceAvatar place={place} category={category} size={52} />}
</div> </div>
<PhotoCredit imageUrl={place.image_url} /> <PhotoCredit imageUrl={place.image_url} />
</div> </div>
@@ -1,106 +0,0 @@
// FE-PLANNER-PHOTOGALLERY-001 to FE-PLANNER-PHOTOGALLERY-003: the place's picture as the
// way into its photos, on the desktop inspector and the phone sheet alike.
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import PlacePhotoGallery from './PlacePhotoGallery';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
const placeEnrichment = vi.fn();
// Partial: the store registry behind resetAllStores needs the real default client.
vi.mock('../../api/client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/client')>()),
mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) },
}));
vi.mock('../Journey/VideoPlayerLazy', () => ({ default: () => null }));
const PLACE = buildPlace({ id: 3, name: 'Tower', lat: 48.85, lng: 2.29, image_url: '/uploads/places/tower.jpg' });
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
describe('PlacePhotoGallery', () => {
it('FE-PLANNER-PHOTOGALLERY-001: with nothing to open the picture stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
render(
<PlacePhotoGallery place={{ ...PLACE, image_url: null }} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
expect(screen.getByTestId('avatar')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-PLANNER-PHOTOGALLERY-002: the trigger is a labelled button that says it is busy while the sources answer', async () => {
let settle: (v: MapsPlaceEnrichmentResult) => void = () => {};
placeEnrichment.mockReturnValue(new Promise<MapsPlaceEnrichmentResult>((r) => (settle = r)));
render(
<PlacePhotoGallery
place={{ ...PLACE, image_url: null }}
language="en"
label="Show photos"
className="rounded-full"
>
<span data-testid="avatar" />
</PlacePhotoGallery>
);
const trigger = screen.getByRole('button', { name: 'Show photos' });
expect(trigger).toHaveClass('rounded-full');
expect(trigger).toContainElement(screen.getByTestId('avatar'));
expect(trigger).toHaveAttribute('aria-busy', 'false');
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-busy', 'true');
expect(trigger).toHaveClass('opacity-70');
await act(async () =>
settle({
photos: [
{
key: 'k0',
url: '/api/maps/place-photo/k0/bytes',
attribution: 'Alice',
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
},
],
facts: [],
description: null,
} as unknown as MapsPlaceEnrichmentResult)
);
expect(trigger).toHaveAttribute('aria-busy', 'false');
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(within(gallery).getByRole('img', { name: 'Alice' })).toHaveAttribute(
'src',
'/api/maps/place-photo/k0/bytes'
);
});
it('FE-PLANNER-PHOTOGALLERY-003: the gallery opens on the body, over everything, and closes again', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { container } = render(
<PlacePhotoGallery place={PLACE} language="en" label="Show photos">
<span data-testid="avatar" />
</PlacePhotoGallery>
);
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }));
const gallery = screen.getByRole('dialog', { name: 'Show photos' });
expect(gallery.parentElement).toBe(document.body);
expect(container).not.toContainElement(gallery);
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/tower.jpg');
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Show photos' })).not.toBeInTheDocument();
});
});
@@ -1,60 +0,0 @@
import { Loader2 } from 'lucide-react';
import React from 'react';
import { createPortal } from 'react-dom';
import type { Place } from '../../types';
import PhotoLightbox from '../Journey/PhotoLightbox';
import { Tooltip } from '../shared/Tooltip';
import { usePlaceGallery } from './usePlaceGallery';
interface PlacePhotoGalleryProps {
place: Place;
language: string;
/** What opens the gallery: the place's picture, or what stands in for it. */
children: React.ReactNode;
/** The open gallery's label, and the trigger's tooltip. */
label: string;
/** The trigger's shape, so its focus ring follows the picture (round on desktop, a rounded tile on the phone). */
className?: string;
}
/**
* The place's picture as the way into its photos: a click opens a full screen
* gallery of its own picture and what its sources know, each credited. While
* there is nothing to show the picture stays a plain picture. The lightbox is
* portalled to <body>: the inspector and the phone sheet sit inside
* transformed containers, where a fixed overlay would be trapped.
*/
export default function PlacePhotoGallery({
place,
language,
children,
label,
className = '',
}: PlacePhotoGalleryProps): React.ReactElement {
const { photos, open, close, isOpen, loading, canOpen } = usePlaceGallery(place, language);
if (!canOpen) return <>{children}</>;
return (
<>
<Tooltip label={label} placement="top">
<button
type="button"
onClick={open}
aria-label={label}
aria-busy={loading}
className={`relative block cursor-zoom-in transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${loading ? 'opacity-70' : ''} ${className}`}
>
{children}
{loading && (
<span className="absolute inset-0 grid place-items-center">
<Loader2 className="h-4 w-4 animate-spin text-content" />
</span>
)}
</button>
</Tooltip>
{isOpen &&
photos.length > 0 &&
createPortal(<PhotoLightbox photos={photos} onClose={close} label={label} />, document.body)}
</>
);
}
@@ -1,62 +0,0 @@
// FE-PLANNER-ENRICHCACHE-001 to FE-PLANNER-ENRICHCACHE-005: the session cache the editor's
// details column and the view's photo gallery share, so a place is asked about once.
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__clearEnrichmentCacheForTests,
cacheKeyFor,
readCachedEnrichment,
storeEnrichment,
type PlaceDetailsSelection,
} from './placeEnrichment';
const SELECTION: PlaceDetailsSelection = { placeId: 'way:1', lat: 50.9, lng: 6.96, name: 'Museum Ludwig' };
const RESULT = { photos: [], facts: [], description: null } as unknown as MapsPlaceEnrichmentResult;
beforeEach(() => {
sessionStorage.clear();
__clearEnrichmentCacheForTests();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('placeEnrichment', () => {
it('FE-PLANNER-ENRICHCACHE-001: the key names the version, the place (or its coordinates) and the language', () => {
expect(cacheKeyFor(SELECTION, 'de')).toBe('enrich_v4_way:1_de');
expect(cacheKeyFor({ lat: 1.5, lng: 2, name: 'Pin' }, 'en')).toBe('enrich_v4_coords:1.5:2_en');
});
it('FE-PLANNER-ENRICHCACHE-002: nothing is cached before it is stored, then both caches hold it', () => {
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
storeEnrichment(SELECTION, 'de', RESULT);
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
expect(JSON.parse(sessionStorage.getItem('enrich_v4_way:1_de') ?? '')).toEqual(RESULT);
// Another language is another answer.
expect(readCachedEnrichment(SELECTION, 'en')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-003: an answer kept in the session outlives the in-memory cache', () => {
storeEnrichment(SELECTION, 'de', RESULT);
__clearEnrichmentCacheForTests();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
// Taken back into memory: the session is not read a second time.
sessionStorage.clear();
expect(readCachedEnrichment(SELECTION, 'de')).toEqual(RESULT);
});
it('FE-PLANNER-ENRICHCACHE-004: a broken session entry reads as nothing cached', () => {
sessionStorage.setItem(cacheKeyFor(SELECTION, 'de'), '{not json');
expect(readCachedEnrichment(SELECTION, 'de')).toBeUndefined();
});
it('FE-PLANNER-ENRICHCACHE-005: a full or blocked session storage still keeps the answer in memory', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(() => storeEnrichment(SELECTION, 'de', RESULT)).not.toThrow();
expect(readCachedEnrichment(SELECTION, 'de')).toBe(RESULT);
});
});
@@ -1,77 +0,0 @@
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
/**
* The place an enrichment consumer is describing — the editor's details column
* and the view-mode photo gallery both use this shape.
*/
export interface PlaceDetailsSelection {
placeId?: string;
lat: number;
lng: number;
name: string;
/** The picked search result, so the server can skip its own details lookup. */
details?: Record<string, unknown>;
}
/**
* Module-level cache plus sessionStorage, same shape as usePlaceDetails in
* PlaceInspector. Clicking back and forth between two search results must not
* pay for the provider fan-out twice, and the gallery must reuse the pictures
* the editor already fetched for the same place.
*/
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>();
/** Test seam: the module-level cache otherwise leaks between cases. */
export function __clearEnrichmentCacheForTests(): void {
enrichmentCache.clear();
}
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined;
} catch {
return undefined;
}
}
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* private mode / quota — the in-memory cache still helps for this session */
}
}
/**
* Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so
* without it the tab that was open while the fix shipped keeps replaying the
* answer the fix was about — and reports it as still broken.
*/
const ENRICH_CACHE_V = 4;
export function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`;
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`;
}
/** The answer if it was already fetched this session, else undefined. */
export function readCachedEnrichment(
selection: PlaceDetailsSelection,
language: string
): MapsPlaceEnrichmentResult | undefined {
const key = cacheKeyFor(selection, language);
const cached = enrichmentCache.get(key) ?? readSession(key);
if (cached) enrichmentCache.set(key, cached);
return cached;
}
export function storeEnrichment(
selection: PlaceDetailsSelection,
language: string,
value: MapsPlaceEnrichmentResult
): void {
const key = cacheKeyFor(selection, language);
enrichmentCache.set(key, value);
writeSession(key, value);
}
@@ -1,65 +0,0 @@
// FE-PLANNER-PLIMGCHOICE-001 to FE-PLANNER-PLIMGCHOICE-006: the editor's picture choice
// as a pure model, read the same way by the desktop dialog and the phone sheet.
import { describe, expect, it } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import { chosenImage, pendingImagePayload, pickableImages, type PendingPlaceImage } from './placeImageChoice';
const CURRENT = '/uploads/places/current.jpg';
describe('pickableImages', () => {
it('FE-PLANNER-PLIMGCHOICE-001: keeps the jpeg, png, gif and webp files and drops the rest', () => {
const jpg = buildTripFile({ mime_type: 'image/jpeg' });
const png = buildTripFile({ mime_type: 'image/png' });
const gif = buildTripFile({ mime_type: 'image/gif' });
const webp = buildTripFile({ mime_type: 'image/webp' });
const heic = buildTripFile({ mime_type: 'image/heic' });
const svg = buildTripFile({ mime_type: 'image/svg+xml' });
const pdf = buildTripFile({ mime_type: 'application/pdf' });
const unknown = buildTripFile({ mime_type: undefined as unknown as string });
expect(pickableImages([jpg, pdf, png, heic, gif, svg, webp, unknown])).toEqual([jpg, png, gif, webp]);
expect(pickableImages([])).toEqual([]);
});
});
describe('chosenImage', () => {
const upload: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const attached: PendingPlaceImage = { kind: 'attached', file: buildTripFile({ id: 77, mime_type: 'image/png' }) };
it('FE-PLANNER-PLIMGCHOICE-002: a waiting picture wins over whatever the field holds', () => {
expect(chosenImage('https://img.example/1.jpg', upload, CURRENT)).toEqual({ kind: 'upload' });
expect(chosenImage(null, attached, CURRENT)).toEqual({ kind: 'attached', fileId: 77 });
});
it('FE-PLANNER-PLIMGCHOICE-003: null drops the picture, whether the place had one or not', () => {
expect(chosenImage(null, null, CURRENT)).toEqual({ kind: 'none' });
expect(chosenImage(null, null, null)).toEqual({ kind: 'none' });
});
it("FE-PLANNER-PLIMGCHOICE-004: a URL is a source's photo, unless it is the picture the place already has", () => {
expect(chosenImage('https://img.example/1.jpg', null, CURRENT)).toEqual({
kind: 'url',
url: 'https://img.example/1.jpg',
});
expect(chosenImage(CURRENT, null, CURRENT)).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGCHOICE-005: an untouched field keeps the current picture, or none on a place without one', () => {
expect(chosenImage(undefined, null, CURRENT)).toEqual({ kind: 'current' });
expect(chosenImage(undefined, null, null)).toEqual({ kind: 'none' });
expect(chosenImage(undefined, null, undefined)).toEqual({ kind: 'none' });
expect(chosenImage('', null, CURRENT)).toEqual({ kind: 'current' });
});
});
describe('pendingImagePayload', () => {
it("FE-PLANNER-PLIMGCHOICE-006: the save carries the file itself, or the attached file's id, or nothing", () => {
const file = new File(['x'], 'a.png');
expect(pendingImagePayload({ kind: 'upload', file, preview: 'blob:a' })).toBe(file);
expect(pendingImagePayload({ kind: 'attached', file: buildTripFile({ id: 9, mime_type: 'image/png' }) })).toEqual({
fileId: 9,
});
expect(pendingImagePayload(null)).toBeUndefined();
});
});
@@ -1,57 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import type { TripFile } from '../../types';
import type { PlaceImageOwn } from './PlaceImagePicker';
const PICKABLE = /^image\/(jpeg|png|gif|webp)$/;
/** The images among a place's files that can stand as its picture (#1242). */
export function pickableImages(files: readonly TripFile[]): TripFile[] {
return files.filter((f) => PICKABLE.test(f.mime_type || ''));
}
/**
* A picture the form will make the place's own once it is saved: a file from
* the device, or an image already attached to the place. Nothing is sent
* before the save, so cancelling the form leaves the picture as it was, and a
* new place can get one too.
*/
export type PendingPlaceImage = { kind: 'upload'; file: File; preview: string } | { kind: 'attached'; file: TripFile };
/** What the picker shows as chosen: the tile that stands for what the save will do. */
export type ChosenImage =
| { kind: 'current' }
| { kind: 'none' }
| { kind: 'url'; url: string }
| { kind: 'upload' }
| { kind: 'attached'; fileId: number };
/**
* The choice the form holds, read as a tile. `imageUrl` is the form's field:
* undefined keeps the place's picture, null drops it, a URL takes a source's
* photo. A waiting picture wins over all of them.
*/
export function chosenImage(
imageUrl: string | null | undefined,
pending: PendingPlaceImage | null,
currentUrl: string | null | undefined
): ChosenImage {
if (pending) return pending.kind === 'upload' ? { kind: 'upload' } : { kind: 'attached', fileId: pending.file.id };
if (imageUrl === null) return { kind: 'none' };
if (imageUrl) return imageUrl === currentUrl ? { kind: 'current' } : { kind: 'url', url: imageUrl };
return currentUrl ? { kind: 'current' } : { kind: 'none' };
}
/** What travels with the save to put a waiting picture in place: the file, or the attached file's id. */
export type PendingImagePayload = File | { fileId: number };
export function pendingImagePayload(pending: PendingPlaceImage | null): PendingImagePayload | undefined {
if (!pending) return undefined;
return pending.kind === 'upload' ? pending.file : { fileId: pending.file.id };
}
/** Whether the picker has a tile to show: its sources' photos, or a picture of the place's own, or an upload. */
export function offersPictures(photos: readonly PlacePhotoCandidate[], own: PlaceImageOwn | undefined): boolean {
if (photos.length > 0) return true;
if (!own) return false;
return !!own.currentUrl || own.attached.length > 0 || !!own.pendingPreview || own.canUpload;
}
@@ -1,39 +0,0 @@
// FE-PLANNER-PHOTOCREDIT-001 to FE-PLANNER-PHOTOCREDIT-003: who a source's photo is credited to,
// and the one-line caption the gallery shows under it.
import type { PlacePhotoCandidate } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { creditOf, photoCaption, sourceLabelFor } from './placePhotoCredit';
function photo(over: Partial<PlacePhotoCandidate> = {}): PlacePhotoCandidate {
return {
key: 'k',
url: '/api/maps/place-photo/k/bytes',
attribution: 'Alice',
license: 'CC BY-SA 4.0',
licenseUrl: null,
sourceUrl: null,
source: 'wikimedia',
...over,
} as PlacePhotoCandidate;
}
describe('placePhotoCredit', () => {
it('FE-PLANNER-PHOTOCREDIT-001: each provider is named by its proper name', () => {
expect(sourceLabelFor('google')).toBe('Google');
expect(sourceLabelFor('wikipedia')).toBe('Wikipedia');
expect(sourceLabelFor('wikimedia')).toBe('Wikimedia Commons');
});
it('FE-PLANNER-PHOTOCREDIT-002: the author is credited, and the provider when there is none', () => {
expect(creditOf(photo())).toBe('Alice');
expect(creditOf(photo({ attribution: null, source: 'google' }))).toBe('Google');
expect(creditOf(photo({ attribution: '', source: 'wikipedia' }))).toBe('Wikipedia');
});
it('FE-PLANNER-PHOTOCREDIT-003: the caption joins the credit and the licence with a comma, not a dot', () => {
expect(photoCaption(photo())).toBe('Alice, CC BY-SA 4.0');
expect(photoCaption(photo({ attribution: null, license: null, source: 'google' }))).toBe('Google');
expect(photoCaption(photo()).includes('·')).toBe(false);
});
});
@@ -1,22 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
/** The name to put under a picture. Google gives no author, so it gets its own name. */
export function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source);
}
/**
* The provider a picture came from, for the pictures that hand us no author.
* Proper names, so they are not translated.
*/
export function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google';
if (source === 'wikipedia') return 'Wikipedia';
return 'Wikimedia Commons';
}
/** Author and licence joined for a single-line caption, with a comma as the rest of the app writes it. */
export function photoCaption(photo: PlacePhotoCandidate): string {
const credit = creditOf(photo);
return photo.license ? `${credit}, ${photo.license}` : credit;
}
@@ -1,6 +1,6 @@
// FE-PLANNER-PLACEACT-001 to -015: the place writes behind both the desktop // FE-PLANNER-PLACEACT-001 to -015: the place writes behind both the desktop
// inspector and the phone place sheet. // inspector and the phone place sheet.
import { act, renderHook } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -8,12 +8,14 @@ import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store'; import { resetAllStores } from '../../../tests/helpers/store';
import { assignmentsApi } from '../../api/client'; import { assignmentsApi } from '../../api/client';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { normalizeImageFile } from '../../utils/convertHeic';
import { import {
participantsWith, participantsWith,
participantsWithout, participantsWithout,
placeActions, placeActions,
splitParticipants, splitParticipants,
usePlaceFileUpload, usePlaceFileUpload,
usePlaceImagePick,
} from './usePlaceActions'; } from './usePlaceActions';
vi.mock('../../utils/convertHeic', () => ({ vi.mock('../../utils/convertHeic', () => ({
@@ -206,3 +208,61 @@ describe('usePlaceFileUpload', () => {
expect(result.current.filesExpanded).toBe(false); expect(result.current.filesExpanded).toBe(false);
}); });
}); });
describe('usePlaceImagePick', () => {
it('FE-PLANNER-PLACEACT-013: converts the picked picture, uploads it and clears the input', async () => {
const upload = vi.fn(async (_file: File) => undefined);
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
const { event, target } = pick([new File(['x'], 'photo.heic')]);
await act(async () => {
await result.current.pickImage(event);
});
expect(normalizeImageFile).toHaveBeenCalledTimes(1);
expect(upload.mock.calls[0][0].name).toBe('normalized-photo.heic');
expect(target.value).toBe('');
expect(result.current.busy).toBe(false);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLACEACT-014: a refused picture is reported and the busy flag drops again', async () => {
let fail: (err: Error) => void = () => undefined;
const upload = vi.fn(() => new Promise<void>((_resolve, reject) => (fail = reject)));
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
let running: Promise<void> = Promise.resolve();
act(() => {
running = result.current.pickImage(pick([new File(['x'], 'a.png')]).event);
});
expect(result.current.busy).toBe(true);
await waitFor(() => expect(upload).toHaveBeenCalled());
await act(async () => {
fail(new Error('unsupported'));
await running;
});
expect(toast.error).toHaveBeenCalledWith('places.imageUploadError');
expect(result.current.busy).toBe(false);
});
it('FE-PLANNER-PLACEACT-015: without an upload or a file the pick only clears the input', async () => {
const upload = vi.fn(async () => undefined);
const noUpload = renderHook(() => usePlaceImagePick(undefined, { error: vi.fn() }));
const nothingPicked = renderHook(() => usePlaceImagePick(upload, { error: vi.fn() }));
const first = pick([new File(['x'], 'a.png')]);
const second = pick([]);
await act(async () => {
await noUpload.result.current.pickImage(first.event);
await nothingPicked.result.current.pickImage(second.event);
});
expect(first.target.value).toBe('');
expect(second.target.value).toBe('');
expect(upload).not.toHaveBeenCalled();
expect(normalizeImageFile).not.toHaveBeenCalled();
act(() => noUpload.result.current.setBusy(true));
expect(noUpload.result.current.busy).toBe(true);
});
});
@@ -4,6 +4,7 @@ import { translateApiError, useTranslation } from '../../i18n';
import type { TripStoreState } from '../../store/tripStore'; import type { TripStoreState } from '../../store/tripStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types'; import type { Place } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
type Translate = (key: string, params?: Record<string, string | number>) => string; type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -132,3 +133,29 @@ export function usePlaceFileUpload(
return { uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload }; return { uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload };
} }
/**
* A picture picked as a place's image (#1136): converted from HEIC where needed,
* uploaded, and a failure said in a toast. `busy` also covers whatever else the
* caller does to the image while it runs. Without an upload the pick does nothing.
*/
export function usePlaceImagePick(upload: ((file: File) => Promise<unknown>) | undefined, toast: PlaceActionToast) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const pickImage = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !upload) return;
setBusy(true);
try {
await upload(await normalizeImageFile(file));
} catch (err: unknown) {
toast.error(translateApiError(t, err, 'places.imageUploadError'));
} finally {
setBusy(false);
}
};
return { busy, setBusy, pickImage };
}
@@ -1,11 +1,9 @@
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-019: the place form state behind the desktop // FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-014: the place form state behind the desktop
// dialog and the phone sheet, its waiting picture, and the inline new category of both // dialog and the phone sheet, and the inline new category of both ('dialog' and 'sheet').
// ('dialog' and 'sheet').
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import type React from 'react'; import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import type { Category, Place } from '../../types'; import type { Category, Place } from '../../types';
import { DEFAULT_FORM } from './PlaceFormModal.helpers'; import { DEFAULT_FORM } from './PlaceFormModal.helpers';
import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm'; import { useNewCategory, usePlaceForm, type NewCategoryOptions } from './usePlaceForm';
@@ -165,88 +163,6 @@ function category(id: number): Category {
return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category; return { id, name: 'Food', color: '#6366f1', icon: 'MapPin' } as unknown as Category;
} }
describe('usePlaceForm, the waiting picture', () => {
let previews: number;
let createObjectURL: MockInstance<typeof URL.createObjectURL>;
let revokeObjectURL: MockInstance<typeof URL.revokeObjectURL>;
beforeEach(() => {
previews = 0;
createObjectURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:preview-${++previews}`);
revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('FE-PLANNER-PLFORM-015: nothing waits on a fresh form', () => {
const { result } = setup();
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-016: a picture from the device waits with its preview and keeps the field untouched', () => {
const { result } = setup();
act(() => result.current.pickImage('https://img.example/1.jpg'));
const picture = file('front.png', 'image/png');
act(() => result.current.stageUpload(picture));
expect(createObjectURL).toHaveBeenCalledWith(picture);
expect(result.current.pendingImage).toEqual({ kind: 'upload', file: picture, preview: 'blob:preview-1' });
// A source's photo picked before gives way to it.
expect(result.current.form.image_url).toBeUndefined();
});
it('FE-PLANNER-PLFORM-017: replacing the waiting upload frees its preview, whatever replaces it', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.stageUpload(file('b.png', 'image/png')));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toMatchObject({ kind: 'upload', preview: 'blob:preview-2' });
const attached = buildTripFile({ id: 8, mime_type: 'image/png' });
act(() => result.current.stageAttached(attached));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toEqual({ kind: 'attached', file: attached });
expect(result.current.form.image_url).toBeUndefined();
// An attached image has no preview of ours to free.
revokeObjectURL.mockClear();
act(() => result.current.pickImage('https://img.example/2.jpg'));
expect(revokeObjectURL).not.toHaveBeenCalled();
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBe('https://img.example/2.jpg');
});
it('FE-PLANNER-PLFORM-018: removing the picture drops what waits and empties the field for the save', () => {
const { result } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
act(() => result.current.removeImage());
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
expect(result.current.pendingImage).toBeNull();
expect(result.current.form.image_url).toBeNull();
// A fresh opening starts without a waiting picture as well.
act(() => result.current.stageUpload(file('b.png', 'image/png')));
act(() => result.current.openForm(DEFAULT_FORM, null, null));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-2');
expect(result.current.pendingImage).toBeNull();
});
it('FE-PLANNER-PLFORM-019: closing the form frees the preview still waiting, and only that one', () => {
const { result, unmount } = setup();
act(() => result.current.stageUpload(file('a.png', 'image/png')));
revokeObjectURL.mockClear();
unmount();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview-1');
const empty = setup();
revokeObjectURL.mockClear();
empty.unmount();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});
describe('useNewCategory', () => { describe('useNewCategory', () => {
function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) { function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) {
const onCreated = vi.fn(); const onCreated = vi.fn();
+4 -45
View File
@@ -1,8 +1,8 @@
import type React from 'react'; import type React from 'react';
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import type { useTranslation } from '../../i18n'; import type { useTranslation } from '../../i18n';
import type { Category, TripFile } from '../../types'; import type { Category } from '../../types';
import { offlineOr } from '../../utils/needsConnection'; import { offlineOr } from '../../utils/needsConnection';
import type { useToast } from '../shared/Toast'; import type { useToast } from '../shared/Toast';
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'; import type { PlaceDetailsSelection } from './PlaceDetailsColumn';
@@ -15,7 +15,6 @@ import {
type PlacePrefill, type PlacePrefill,
type ResultField, type ResultField,
} from './PlaceFormModal.helpers'; } from './PlaceFormModal.helpers';
import type { PendingPlaceImage } from './placeImageChoice';
type Translate = ReturnType<typeof useTranslation>['t']; type Translate = ReturnType<typeof useTranslation>['t'];
type Toast = ReturnType<typeof useToast>; type Toast = ReturnType<typeof useToast>;
@@ -41,20 +40,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
// user's and survives. See mergeResult. // user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set()); const autoFilledRef = useRef<Set<ResultField>>(new Set());
const [pendingFiles, setPendingFiles] = useState<File[]>([]); const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// The picture that becomes the place's own when the form is saved.
const [pendingImage, setPendingImageState] = useState<PendingPlaceImage | null>(null);
const previewRef = useRef<string | null>(null);
const setPendingImage = (next: PendingPlaceImage | null) => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
previewRef.current = next?.kind === 'upload' ? next.preview : null;
setPendingImageState(next);
};
useEffect(
() => () => {
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
},
[]
);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null); const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
// What the details column is describing. Null until there is a place to describe. // What the details column is describing. Null until there is a place to describe.
@@ -74,7 +59,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
autoFilledRef.current = openingAutoFilled(place, prefill); autoFilledRef.current = openingAutoFilled(place, prefill);
setDetailsSelection(openingDetailsSelection(place, prefill)); setDetailsSelection(openingDetailsSelection(place, prefill));
setPendingFiles([]); setPendingFiles([]);
setPendingImage(null);
setDuplicateWarning(null); setDuplicateWarning(null);
}; };
@@ -107,29 +91,8 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
} }
}; };
/** A source's photo picked in the details column, or the place's own picture again. */ /** A hero image picked in the details column, or none. */
const pickImage = (url: string | null) => { const pickImage = (url: string | null) => setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
setPendingImage(null);
setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
};
/** A picture from the device, shown at once and uploaded when the form is saved. */
const stageUpload = (file: File) => {
setPendingImage({ kind: 'upload', file, preview: URL.createObjectURL(file) });
setForm((prev) => ({ ...prev, image_url: undefined }));
};
/** An image already attached to the place, made its picture when the form is saved. */
const stageAttached = (file: TripFile) => {
setPendingImage({ kind: 'attached', file });
setForm((prev) => ({ ...prev, image_url: undefined }));
};
/** No picture at all: the save drops the place's own one. */
const removeImage = () => {
setPendingImage(null);
setForm((prev) => ({ ...prev, image_url: null }));
};
/** The details column's description, taken over into the form. */ /** The details column's description, taken over into the form. */
const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text })); const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text }));
@@ -164,10 +127,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
openForm, openForm,
handleChange, handleChange,
pickImage, pickImage,
pendingImage,
stageUpload,
stageAttached,
removeImage,
adoptDescription, adoptDescription,
warnIfDuplicate, warnIfDuplicate,
}; };
@@ -1,227 +0,0 @@
// FE-PLANNER-PLGALLERY-001 to FE-PLANNER-PLGALLERY-011: the pictures of one place for the
// view's full screen gallery: its own picture first, the sources' photos asked for on
// open, the shared session cache, and an answer that comes back for a place already left.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { MapsPlaceEnrichmentResult, PlacePhotoCandidate } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import type { Place } from '../../types';
import { __clearEnrichmentCacheForTests, readCachedEnrichment, storeEnrichment } from './placeEnrichment';
import { usePlaceGallery } from './usePlaceGallery';
const placeEnrichment = vi.fn();
vi.mock('../../repo/placePictureRepo', () => ({
placePictureRepo: { enrichment: (...a: unknown[]) => placeEnrichment(...a) },
}));
// One toast for every render, the way the real hook memoises it.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const CUSTOM = '/uploads/places/own.jpg';
const PHOTO: PlacePhotoCandidate = {
key: 'gp~p0',
url: '/api/maps/place-photo/gp~p0/bytes',
attribution: 'Alice',
license: 'CC BY 4.0',
licenseUrl: null,
sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg',
source: 'wikimedia',
} as PlacePhotoCandidate;
const GOOGLE: PlacePhotoCandidate = {
key: 'gp~p1',
url: '/api/maps/place-photo/gp~p1/bytes',
attribution: null,
license: null,
licenseUrl: null,
sourceUrl: null,
source: 'google',
} as PlacePhotoCandidate;
const result = (photos: PlacePhotoCandidate[]) =>
({ photos, facts: [], description: null }) as unknown as MapsPlaceEnrichmentResult;
const LAKE = buildPlace({ id: 1, name: 'Lake', lat: 47.5, lng: 11.2, google_place_id: 'gp-lake' });
const CASTLE = buildPlace({ id: 2, name: 'Castle', lat: 48.1, lng: 11.6, osm_id: 'way:2' });
function deferred<T>() {
let resolve: (v: T) => void = () => {};
let reject: (e: unknown) => void = () => {};
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup(place: Place, language = 'de') {
return renderHook(({ p, lang }) => usePlaceGallery(p, lang), { initialProps: { p: place, lang: language } });
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { placesEnrichEnabled: true });
placeEnrichment.mockReset();
placeEnrichment.mockResolvedValue(result([PHOTO]));
sessionStorage.clear();
__clearEnrichmentCacheForTests();
Object.values(toast).forEach((fn) => fn.mockClear());
});
describe('usePlaceGallery', () => {
it('FE-PLANNER-PLGALLERY-001: it can open with a picture of its own, or with coordinates the sources can be asked about', () => {
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
expect(setup(LAKE).result.current.canOpen).toBe(true);
expect(setup({ ...LAKE, lat: null, lng: null } as unknown as Place).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, lat: 'north' as unknown as number }).result.current.canOpen).toBe(false);
seedStore(useAuthStore, { placesEnrichEnabled: false });
expect(setup(LAKE).result.current.canOpen).toBe(false);
expect(setup({ ...LAKE, image_url: CUSTOM }).result.current.canOpen).toBe(true);
});
it('FE-PLANNER-PLGALLERY-002: with the sources switched off its own picture opens at once, alone', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup({ ...LAKE, image_url: CUSTOM });
expect(r.current.photos).toEqual([{ id: 'custom', src: CUSTOM, caption: null, captionHref: null }]);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.close());
expect(r.current.isOpen).toBe(false);
});
it('FE-PLANNER-PLGALLERY-003: nothing of its own and nobody to ask says so instead of opening', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(false);
expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos');
});
it('FE-PLANNER-PLGALLERY-004: the sources are asked on open, and their photos join with a linked credit', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r } = setup(LAKE);
expect(placeEnrichment).not.toHaveBeenCalled();
act(() => r.current.open());
expect(r.current.loading).toBe(true);
expect(r.current.isOpen).toBe(false);
expect(placeEnrichment).toHaveBeenCalledWith({
placeId: 'gp-lake',
lat: 47.5,
lng: 11.2,
name: 'Lake',
lang: 'de',
});
await act(async () => pending.resolve(result([PHOTO, GOOGLE])));
expect(r.current.loading).toBe(false);
expect(r.current.isOpen).toBe(true);
expect(r.current.photos).toEqual([
{ id: 'gp~p0', src: PHOTO.url, caption: 'Alice, CC BY 4.0', captionHref: PHOTO.sourceUrl },
{ id: 'gp~p1', src: GOOGLE.url, caption: 'Google', captionHref: null },
]);
// Kept for the editor's details column and the next open.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-005: a second open uses the answer it already has', async () => {
const { result: r } = setup(LAKE);
act(() => r.current.open());
await waitFor(() => expect(r.current.isOpen).toBe(true));
act(() => r.current.close());
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-006: its own picture leads, and a source photo of the same picture is not shown twice', async () => {
placeEnrichment.mockResolvedValue(result([PHOTO, GOOGLE]));
const { result: r } = setup({ ...LAKE, image_url: PHOTO.url });
act(() => r.current.open());
// Open at once on its own picture, while the sources are still asked.
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(true);
await waitFor(() => expect(r.current.loading).toBe(false));
expect(r.current.photos.map((p) => p.id)).toEqual(['custom', 'gp~p1']);
});
it('FE-PLANNER-PLGALLERY-007: when the sources have nothing either, it says so, the second time too', async () => {
placeEnrichment.mockResolvedValue(result([]));
const { result: r } = setup(CASTLE);
act(() => r.current.open());
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('places.details.noPhotos'));
expect(r.current.isOpen).toBe(false);
// The OSM id is what the server is asked about when there is no Google one.
expect(placeEnrichment.mock.calls[0][0]).toMatchObject({ placeId: 'way:2' });
act(() => r.current.open());
expect(toast.info).toHaveBeenCalledTimes(2);
expect(placeEnrichment).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-PLGALLERY-008: an answer this session already holds opens without asking', () => {
storeEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de', result([PHOTO]));
const { result: r } = setup(LAKE);
act(() => r.current.open());
expect(r.current.isOpen).toBe(true);
expect(r.current.loading).toBe(false);
expect(placeEnrichment).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-009: a failed request is reported, unless its own picture is already on screen', async () => {
placeEnrichment.mockRejectedValue(new Error('network'));
const bare = setup(LAKE);
act(() => bare.result.current.open());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('places.details.error'));
expect(bare.result.current.loading).toBe(false);
bare.unmount();
toast.error.mockClear();
const pictured = setup({ ...CASTLE, image_url: CUSTOM });
act(() => pictured.result.current.open());
await waitFor(() => expect(pictured.result.current.loading).toBe(false));
expect(pictured.result.current.isOpen).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-PLANNER-PLGALLERY-010: switching place while the sources answer drops that answer: nothing opens, nothing joins', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
expect(r.current.loading).toBe(true);
rerender({ p: CASTLE, lang: 'de' });
expect(r.current.loading).toBe(false);
await act(async () => pending.resolve(result([PHOTO])));
expect(r.current.isOpen).toBe(false);
expect(r.current.loading).toBe(false);
expect(r.current.photos).toEqual([]);
expect(toast.error).not.toHaveBeenCalled();
// The answer still belongs to the first place, so it is kept for it.
expect(readCachedEnrichment({ placeId: 'gp-lake', lat: 47.5, lng: 11.2, name: 'Lake' }, 'de')).toBeTruthy();
});
it('FE-PLANNER-PLGALLERY-011: a failure that arrives after the switch is not reported over the next place', async () => {
const pending = deferred<MapsPlaceEnrichmentResult>();
placeEnrichment.mockReturnValue(pending.promise);
const { result: r, rerender } = setup(LAKE);
act(() => r.current.open());
act(() => r.current.close());
rerender({ p: CASTLE, lang: 'de' });
await act(async () => pending.reject(new Error('network')));
expect(toast.error).not.toHaveBeenCalled();
expect(r.current.isOpen).toBe(false);
});
});
@@ -1,136 +0,0 @@
import type { PlacePhotoCandidate } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translateApiError, useTranslation } from '../../i18n';
import { placePictureRepo } from '../../repo/placePictureRepo';
import { useAuthStore } from '../../store/authStore';
import type { Place } from '../../types';
import { useToast } from '../shared/Toast';
import { readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment';
import { photoCaption } from './placePhotoCredit';
/** What the lightbox renders; mirrors Journey/PhotoLightbox's private photo shape. */
interface GalleryPhoto {
id: string;
src: string;
caption: string | null;
/** The photo's source page, which the credit links to. */
captionHref: string | null;
}
interface UsePlaceGallery {
photos: GalleryPhoto[];
open: () => void;
close: () => void;
isOpen: boolean;
/** Loading enrichment; the trigger can show a spinner while it waits. */
loading: boolean;
/** False when there is no picture and nobody who could supply one. */
canOpen: boolean;
}
/**
* The pictures of one place, for the full-screen view-mode gallery.
*
* The custom image (if any) leads, then the provider candidates the editor's
* details column already uses. Enrichment is fetched lazily on open: fanning
* out to Google/Wikimedia for every place a user merely selects is not worth
* it, and the shared session cache makes a second open instant.
*/
export function usePlaceGallery(place: Place, language: string): UsePlaceGallery {
const placesEnrichEnabled = useAuthStore((s) => s.placesEnrichEnabled);
const { t } = useTranslation();
const toast = useToast();
// Each piece of state names the place it belongs to: a different place reads
// as nothing loaded, nothing open, so the previous answer never flashes.
const [candidatesFor, setCandidatesFor] = useState<{ placeId: number; list: PlacePhotoCandidate[] } | null>(null);
const [openFor, setOpenFor] = useState<number | null>(null);
const [loadingFor, setLoadingFor] = useState<number | null>(null);
const placeId = place.id;
const candidates = candidatesFor?.placeId === placeId ? candidatesFor.list : null;
const isOpen = openFor === placeId;
const loading = loadingFor === placeId;
// The place on screen now, for an answer that arrives after the user moved on.
const shownRef = useRef(placeId);
useEffect(() => {
shownRef.current = placeId;
}, [placeId]);
const selection = useMemo<PlaceDetailsSelection | null>(() => {
if (place.lat == null || place.lng == null) return null;
const lat = Number(place.lat);
const lng = Number(place.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return {
placeId: place.google_place_id || place.amap_poi_id || place.osm_id || undefined,
lat,
lng,
name: place.name || '',
};
}, [place.google_place_id, place.amap_poi_id, place.osm_id, place.lat, place.lng, place.name]);
const photos = useMemo<GalleryPhoto[]>(() => {
const list: GalleryPhoto[] = [];
if (place.image_url) list.push({ id: 'custom', src: place.image_url, caption: null, captionHref: null });
for (const candidate of candidates ?? []) {
if (list.some((p) => p.src === candidate.url)) continue;
list.push({
id: candidate.key,
src: candidate.url,
caption: photoCaption(candidate),
captionHref: candidate.sourceUrl ?? null,
});
}
return list;
}, [place.image_url, candidates]);
const canOpen = !!place.image_url || (placesEnrichEnabled && !!selection);
const close = useCallback(() => setOpenFor(null), []);
const open = useCallback(() => {
// The custom image is already on screen — open now and let any provider
// pictures join it as they arrive.
if (place.image_url) setOpenFor(placeId);
if (!placesEnrichEnabled || !selection) {
if (!place.image_url) toast.info(t('places.details.noPhotos'));
return;
}
if (candidates !== null) {
if (place.image_url || candidates.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
return;
}
const apply = (list: PlacePhotoCandidate[]) => {
setCandidatesFor({ placeId, list });
if (place.image_url || list.length) setOpenFor(placeId);
else toast.info(t('places.details.noPhotos'));
};
const cached = readCachedEnrichment(selection, language);
if (cached) {
apply(cached.photos);
return;
}
// An answer for a place the user has left is kept for that place, but opens nothing and says nothing.
const current = () => shownRef.current === placeId;
setLoadingFor(placeId);
placePictureRepo
.enrichment({ ...selection, lang: language })
.then((result) => {
storeEnrichment(selection, language, result);
if (current()) apply(result.photos);
else setCandidatesFor({ placeId, list: result.photos });
})
.catch((err: unknown) => {
if (current() && !place.image_url) toast.error(translateApiError(t, err, 'places.details.error'));
})
.finally(() => {
setLoadingFor((now) => (now === placeId ? null : now));
});
}, [place.image_url, placeId, placesEnrichEnabled, selection, candidates, language, t, toast]);
return { photos, open, close, isOpen, loading, canOpen };
}
@@ -1,106 +0,0 @@
// FE-PLANNER-PLIMGFIELD-001 to FE-PLANNER-PLIMGFIELD-004: the editor's picture field the
// desktop dialog and the phone sheet share: the place's attached images, the waiting
// picture's preview, and which tile stands for the choice.
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildReservation, buildTripFile } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTripStore } from '../../store/tripStore';
import type { TripFile } from '../../types';
import type { PendingPlaceImage } from './placeImageChoice';
import { usePlaceImageField } from './usePlaceImageField';
const PHOTO = buildTripFile({ id: 1, place_id: 5, mime_type: 'image/jpeg', original_name: 'front.jpg' });
const PDF = buildTripFile({ id: 2, place_id: 5, mime_type: 'application/pdf' });
const ELSEWHERE = buildTripFile({ id: 3, place_id: 6, mime_type: 'image/png' });
const ON_BOOKING = buildTripFile({
id: 4,
place_id: null,
reservation_id: 90,
mime_type: 'image/webp',
} as Partial<TripFile>);
const handlers = () => ({ stageUpload: vi.fn(), stageAttached: vi.fn(), removeImage: vi.fn() });
beforeEach(() => {
resetAllStores();
useTripStore.setState({
files: [PHOTO, PDF, ELSEWHERE, ON_BOOKING],
reservations: [buildReservation({ id: 90, place_id: 5 })],
} as never);
});
describe('usePlaceImageField', () => {
it("FE-PLANNER-PLIMGFIELD-001: offers the place's attached images, its booking's included, and its current picture", () => {
const h = handlers();
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: null,
canUploadFiles: true,
...h,
})
);
expect(result.current.own.attached).toEqual([PHOTO, ON_BOOKING]);
expect(result.current.own.currentUrl).toBe('/uploads/places/5.jpg');
expect(result.current.own.pendingPreview).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.own.onUpload).toBe(h.stageUpload);
expect(result.current.own.onPickAttached).toBe(h.stageAttached);
expect(result.current.own.onRemove).toBe(h.removeImage);
expect(result.current.chosen).toEqual({ kind: 'current' });
});
it('FE-PLANNER-PLIMGFIELD-002: a new place has nothing attached and no picture, but can still be given one', () => {
const { result } = renderHook(() =>
usePlaceImageField({ place: null, imageUrl: undefined, pendingImage: null, canUploadFiles: true, ...handlers() })
);
expect(result.current.own.attached).toEqual([]);
expect(result.current.own.currentUrl).toBeNull();
expect(result.current.own.canUpload).toBe(true);
expect(result.current.chosen).toEqual({ kind: 'none' });
});
it('FE-PLANNER-PLIMGFIELD-003: a waiting upload shows as its preview and is the choice', () => {
const pending: PendingPlaceImage = { kind: 'upload', file: new File(['x'], 'a.png'), preview: 'blob:a' };
const { result } = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: null },
imageUrl: undefined,
pendingImage: pending,
canUploadFiles: false,
...handlers(),
})
);
expect(result.current.own.pendingPreview).toBe('blob:a');
expect(result.current.own.canUpload).toBe(false);
expect(result.current.chosen).toEqual({ kind: 'upload' });
});
it('FE-PLANNER-PLIMGFIELD-004: a waiting attached image has no preview, and a dropped picture reads as none', () => {
const attached = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: undefined,
pendingImage: { kind: 'attached', file: PHOTO },
canUploadFiles: true,
...handlers(),
})
);
expect(attached.result.current.own.pendingPreview).toBeNull();
expect(attached.result.current.chosen).toEqual({ kind: 'attached', fileId: 1 });
const dropped = renderHook(() =>
usePlaceImageField({
place: { id: 5, image_url: '/uploads/places/5.jpg' },
imageUrl: null,
pendingImage: null,
canUploadFiles: true,
...handlers(),
})
);
expect(dropped.result.current.chosen).toEqual({ kind: 'none' });
});
});
@@ -1,57 +0,0 @@
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTripStore } from '../../store/tripStore';
import type { Place, TripFile } from '../../types';
import { filesForPlace } from '../../utils/placeFiles';
import type { PlaceImageOwn } from './PlaceImagePicker';
import { chosenImage, pickableImages, type ChosenImage, type PendingPlaceImage } from './placeImageChoice';
interface PlaceImageFieldOptions {
/** The place being edited; null or undefined while a new one is made. */
place: Pick<Place, 'id' | 'image_url'> | null | undefined;
/** The form's image field: undefined keeps the picture, null drops it, a URL takes a source's photo. */
imageUrl: string | null | undefined;
pendingImage: PendingPlaceImage | null;
canUploadFiles: boolean;
stageUpload: (file: File) => void;
stageAttached: (file: TripFile) => void;
removeImage: () => void;
}
/**
* The editor's picture of a place, the same for the desktop dialog and the
* phone sheet: the picture it has now, the images attached to it, the one
* waiting for the save, and which tile of the picker stands for the choice.
* A new place has no attached files yet; it can still be given a picture from
* the device, uploaded once the save has made it.
*/
export function usePlaceImageField({
place,
imageUrl,
pendingImage,
canUploadFiles,
stageUpload,
stageAttached,
removeImage,
}: PlaceImageFieldOptions): { own: PlaceImageOwn; chosen: ChosenImage } {
const { files, reservations } = useTripStore(useShallow((s) => ({ files: s.files, reservations: s.reservations })));
const placeId = place?.id;
const attached = useMemo(
() => (placeId ? pickableImages(filesForPlace(files, placeId, reservations)) : []),
[files, placeId, reservations]
);
const currentUrl = place?.image_url ?? null;
return {
own: {
currentUrl,
attached,
pendingPreview: pendingImage?.kind === 'upload' ? pendingImage.preview : null,
canUpload: canUploadFiles,
onUpload: stageUpload,
onPickAttached: stageAttached,
onRemove: removeImage,
},
chosen: chosenImage(imageUrl, pendingImage, currentUrl),
};
}
@@ -1,17 +1,35 @@
import { MAX_WAY_PINS, pinWay, type PinnedWay, type WayPin } from '@trek/shared' import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
import type { RoadLine } from '../Map/RouteCalculator'
import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip' import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes' import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives' import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives'
/** The most points one choice may pin to the road (see `MAX_WAY_PINS`). */ /**
export const MAX_PINS = MAX_WAY_PINS * The most points one choice may pin to the road.
*
* Every pin is one more request to a host that allows one a second, and one more handle on
* the map somebody has to understand later. A way that three points cannot hold is not
* held by the router at all: a ferry OSRM will not board, or a class the day is not
* weighed against.
*/
export const MAX_PINS = 3
/** A point a choice is held by, in the order the chosen road drives through them. */ /** A point a choice is held by, in the order the chosen road drives through them. */
export type Pin = WayPin export interface Pin {
lat: number
lng: number
}
/** Whether the rail's router drives an offer, and the pins that make it do so. */ /** Whether the rail's router drives an offer, and the pins that make it do so. */
export type PinProof = PinnedWay<RailLegRoute> export interface PinProof {
/** True when the router drove the offer with `pins` in place. */
held: boolean
/** The pins that held it, in the order the offer drives through them. Empty otherwise. */
pins: Pin[]
/** True when the last answer came from a stand-in engine, which proves nothing either way. */
fellBack: boolean
/** The router's answer to the last attempt. */
last: RailLegRoute
}
/** /**
* The fewest points that make the rail's own router drive `offer`, found by asking it. * The fewest points that make the rail's own router drive `offer`, found by asking it.
@@ -19,10 +37,19 @@ export type PinProof = PinnedWay<RailLegRoute>
* A choice used to be stored as one via at the point where the offer strayed furthest, * A choice used to be stored as one via at the point where the offer strayed furthest,
* and nobody asked whether the router then drove the offer. Often it did not. A point on * and nobody asked whether the router then drove the offer. Often it did not. A point on
* a ferry is pulled to the nearest pier by OSRM, which never boards one, so Amsterdam to * a ferry is pulled to the nearest pier by OSRM, which never boards one, so Amsterdam to
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. So * Newcastle "without the motorway" came back as a thousand kilometres through Calais. An
* the check is the router itself, the way `pinWay` asks it; the spacing between the * offer weighed away from motorways by the second engine is held by one point for a few
* requests is the router's own (`RailLegRouter.route`). The server asks the same way for * kilometres, and the motorway takes the rest. Either way the choice looked saved and the
* the ways of a hike's leg. * drive did something else.
*
* So the check is the router itself. The router's own preference starts with no pin; any
* other offer with the point furthest from the road being driven. After each answer that
* is not the offer, the point of the offer furthest from that answer joins in, placed in
* the order the offer drives through them, until the answer is the offer or `maxPins` is
* reached. The spacing between the requests is the router's own (`RailLegRouter.route`).
*
* An answer from a stand-in engine proves nothing about the one that will draw the leg,
* so it ends the check without a verdict on the road.
*/ */
export async function pinAlternative({ export async function pinAlternative({
offer, offer,
@@ -38,9 +65,23 @@ export async function pinAlternative({
signal: AbortSignal signal: AbortSignal
maxPins?: number maxPins?: number
}): Promise<PinProof> { }): Promise<PinProof> {
const proof = await pinWay({ offer, current, route: (pins) => route(pins, signal), signal, maxPins }) const placed: { index: number; lat: number; lng: number }[] = []
if (!proof) throw new DOMException('The check was abandoned', 'AbortError') if (!offer.direct) {
return proof const far = furthestFrom(offer.coordinates, current?.coordinates ?? [])
if (far) placed.push(far)
}
for (;;) {
if (signal.aborted) throw new DOMException('The check was abandoned', 'AbortError')
const last = await route(placed.map(({ lat, lng }) => ({ lat, lng })), signal)
if (last.fellBack) return { held: false, pins: [], fellBack: true, last }
if (sameRoad(last, offer)) return { held: true, pins: placed.map(({ lat, lng }) => ({ lat, lng })), fellBack: false, last }
if (placed.length >= maxPins) return { held: false, pins: [], fellBack: false, last }
const far = furthestFrom(offer.coordinates, last.coordinates)
// The same point again would ask the same question and earn the same answer.
if (!far || placed.some(p => p.index === far.index)) return { held: false, pins: [], fellBack: false, last }
placed.push(far)
placed.sort((a, b) => a.index - b.index)
}
} }
/** /**
@@ -1,6 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { sameRoad } from '@trek/shared' import { calculateAlternatives, sameRoad, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
import { calculateAlternatives, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
import type { RailLegRoute, RailLegRouter } from './useRoadtripRoutes' import type { RailLegRoute, RailLegRouter } from './useRoadtripRoutes'
/** One offered way of driving a leg, with what it means for this trip. */ /** One offered way of driving a leg, with what it means for this trip. */
@@ -91,7 +91,7 @@ function SheetView({
height: `${sheet.height + box.bleed * 2}mm`, height: `${sheet.height + box.bleed * 2}mm`,
// The spread's own paper colour, carried out into the bleed. Without // The spread's own paper colour, carried out into the bleed. Without
// it a coloured page would be cut with a white edge on three sides. // it a coloured page would be cut with a white edge on three sides.
background: (sheet.spread.backgroundPaint ? null : sheet.spread.background) ?? '#ffffff', // theme-lint-disable: paper, not app chrome background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
@@ -1,159 +0,0 @@
import { type BookLineElement, MAX_LINE_POINTS, bookLinePoints, bookLineWithPoints } from '@trek/shared';
import { useRef } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { constrainPoint } from './lineSnap';
/**
* The canvas handles of a selected line: one on each of its points, to drag
* it where it should go, and a smaller one half way along each segment that
* adds a point when it is pulled. Double-clicking a point between the ends
* takes it out again.
*
* Drawn inside the selection box, which carries the line's turn, at the
* points' own fractions of it, so they sit on the drawing at any zoom and any
* angle. The drag itself is measured on the spread, where the pointer is, and
* the frame is refitted around the moved points by `bookLineWithPoints`, the
* same refit the book engine uses.
*
* While dragging, a segment within a couple of degrees of level or plumb is
* made exactly level or plumb, and Shift holds it to 15 degree steps.
*/
type Pt = [number, number];
interface Drag {
index: number;
start: Pt;
pointerX: number;
pointerY: number;
points: Pt[];
}
export function LineHandles({
el,
spreadIndex,
scaled,
t,
}: {
el: BookLineElement;
spreadIndex: number;
/** Screen pixels per millimetre at the current zoom. */
scaled: number;
t: (k: string) => string;
}) {
const update = useStudioStore((s) => s.updateElement);
const beginGesture = useStudioStore((s) => s.beginGesture);
const endGesture = useStudioStore((s) => s.endGesture);
const commit = useStudioStore((s) => s.commit);
const drag = useRef<Drag | null>(null);
const place = (points: Pt[]) => {
const next = bookLineWithPoints(el, points);
update(spreadIndex, el.id, { frame: next.frame, points: next.points });
};
const grab = (e: React.PointerEvent, index: number, points: Pt[]) => {
e.stopPropagation();
e.preventDefault();
(e.target as Element).setPointerCapture?.(e.pointerId);
beginGesture();
drag.current = { index, start: points[index], pointerX: e.clientX, pointerY: e.clientY, points };
};
const onMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
e.stopPropagation();
const raw: Pt = [d.start[0] + (e.clientX - d.pointerX) / scaled, d.start[1] + (e.clientY - d.pointerY) / scaled];
const anchor = d.points[d.index === 0 ? 1 : d.index - 1];
const points = d.points.slice();
points[d.index] = constrainPoint(raw, anchor, e.shiftKey);
place(points);
};
const onUp = (e: React.PointerEvent) => {
if (!drag.current) return;
e.stopPropagation();
drag.current = null;
endGesture();
};
/** Pull a new point out of the middle of segment `i`. */
const insert = (e: React.PointerEvent, i: number) => {
const world = bookLinePoints(el) as Pt[];
const a = world[i];
const b = world[i + 1];
const points = [...world.slice(0, i + 1), [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as Pt, ...world.slice(i + 1)];
grab(e, i + 1, points);
place(points);
};
const remove = (i: number) => {
const world = bookLinePoints(el) as Pt[];
if (world.length <= 2) return;
const next = bookLineWithPoints(
el,
world.filter((_, k) => k !== i)
);
commit((d) => ({
...d,
spreads: d.spreads.map((sp, k) =>
k !== spreadIndex
? sp
: {
...sp,
elements: sp.elements.map((x) =>
x.kind === 'line' && x.id === el.id ? { ...x, frame: next.frame, points: next.points } : x
),
}
),
}));
};
// A point on the drawing, measured from the frame, not a side of the text.
const pct = (p: { x: number; y: number }) => ({ left: `${p.x * 100}%`, top: `${p.y * 100}%` }); // rtl-lint-disable
const canAdd = el.points.length < MAX_LINE_POINTS;
return (
<>
{canAdd &&
el.points.slice(1).map((p, i) => {
const a = el.points[i];
return (
<span
key={`m${i}`}
className="st-handle is-corner"
style={{
...pct({ x: (a.x + p.x) / 2, y: (a.y + p.y) / 2 }),
cursor: 'copy',
transform: 'scale(.7)',
opacity: 0.7,
}}
title={t('journey.studio.addPoint')}
onPointerDown={(e) => insert(e, i)}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
/>
);
})}
{el.points.map((p, i) => (
<span
key={`p${i}`}
className="st-handle is-corner"
style={{ ...pct(p), cursor: 'crosshair' }}
title={i > 0 && i < el.points.length - 1 ? t('journey.studio.removePoint') : undefined}
onPointerDown={(e) => grab(e, i, bookLinePoints(el) as Pt[])}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onDoubleClick={(e) => {
e.stopPropagation();
if (i > 0 && i < el.points.length - 1) remove(i);
}}
/>
))}
</>
);
}
-46
View File
@@ -1,46 +0,0 @@
import type { BookLineElement } from '@trek/shared';
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import { type LineGeometry, lineGeometry } from './lineRender';
/** The paths of a line's geometry in one colour: the body stroked, the heads and the ornament filled. */
export function LinePaths({ g, color, width }: { g: LineGeometry; color: string; width: number }) {
const stroke = { fill: 'none', stroke: color, strokeWidth: width, strokeLinejoin: 'round' as const };
return (
<>
{g.body.map((d, i) => (
<path key={`b${i}`} d={d} {...stroke} strokeLinecap={g.cap} strokeDasharray={g.dash} />
))}
{g.strokes.map((d, i) => (
<path key={`s${i}`} d={d} {...stroke} strokeLinecap="round" />
))}
{g.fills.map((d, i) => (
<path key={`f${i}`} d={d} fill={color} />
))}
</>
);
}
/**
* A line element, drawn: its body stroked, its heads and ornament filled, all
* in the millimetres of its own frame (one SVG unit is one millimetre, as for
* the shapes). Overflow stays visible so a head on a thin frame is not cut.
*/
export function LineView({ el, frameStyle }: { el: BookLineElement; frameStyle: CSSProperties }) {
const { w, h } = el.frame;
const g = useMemo(() => lineGeometry(el, w, h, el.id), [el, w, h]);
return (
<div style={frameStyle}>
<svg
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
style={{ display: 'block', overflow: 'visible' }}
aria-hidden
>
<LinePaths g={g} color={el.stroke} width={el.strokeWidth} />
</svg>
</div>
);
}
-433
View File
@@ -1,433 +0,0 @@
import { photoLookOf, type BookFrame, type BookPhotoElement, type BookShapeId, type PhotoLook } from '@trek/shared';
import type { CSSProperties, ReactNode } from 'react';
import { fontStack } from './bookFonts';
import { photoSrc } from './bookRender';
import { usePaintId } from './paint/usePaintId';
import { pictureFilter, silhouetteShadows, svgFilterMatrices, zoomPlacement } from './photo/photoLook';
import { photoClip } from './shapeOutline';
import { HOLED_SHAPES, SHAPE_PATHS, scalePath } from './shapes';
/**
* A picture on the page: possibly cut to a shape, possibly sitting in
* something, possibly wearing a look (book-photo-look.schema.ts in shared).
*
* Split out of SpreadView.tsx, which draws every kind and had no room left
* for the look. It is still the one renderer: the editor, the page
* thumbnails and the print path all draw a photo through this.
*
* The mask and the decoration are independent on purpose: a heart in a Polaroid
* is a silly thing to want and there is no reason to stop anyone wanting it, and
* keeping them separate means neither has to know about the other.
*
* ── The look, and how each part prints ───────────────────────────────────
*
* Border, hard shadow and cutout are flat shapes of one colour, so they stay
* vector in the PDF. On a rectangular frame (no mask, or a card such as a
* Polaroid around it) the cutout and the shadow are box shadows without blur,
* which follow the corner radius; on a frame cut to a shape they are that
* shape's path, drawn behind the picture. The border is drawn over the edge of
* the picture, inside the frame, so the frame stays the box the fold and trim
* checks measure. Zoom and mirror move the picture inside its clip and change
* nothing around it. Adjustments, warmth and a duotone are filters and print
* as a raster, which the inspector says.
*
* Every SVG id is per instance (usePaintId): the page view draws each spread twice,
* and two clip paths with one id cut the second picture with the first one's
* shape.
*/
/** Two decimals of a millimetre, matching what the document itself stores. */
const round2 = (n: number) => Math.round(n * 100) / 100;
const PHOTO_FILTERS: Record<string, string | undefined> = {
none: undefined,
bw: 'grayscale(1) contrast(1.05)',
warm: 'saturate(1.1) sepia(0.16)',
cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)',
// Faded, the way a print left in the light goes: lifted blacks, less colour.
fade: 'saturate(0.72) contrast(0.88) brightness(1.08)',
contrast: 'contrast(1.22) saturate(1.06)',
};
/**
* How much of the frame the decoration around a picture eats, as a fraction of
* the shorter side.
*
* A Polaroid's chin is the reason this is a table rather than one number: the
* bottom border is roughly three times the others, and getting that ratio wrong
* is the difference between a Polaroid and a photo with a white edge.
*/
const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
none: { pad: 0, bottom: 0 },
polaroid: { pad: 0.055, bottom: 0.17 },
white: { pad: 0.035, bottom: 0.035 },
shadow: { pad: 0, bottom: 0 },
film: { pad: 0.075, bottom: 0.075 },
tape: { pad: 0, bottom: 0 },
};
/** The frame styles that draw a card around the picture, which makes the frame's outline a rectangle. */
const CARDS = new Set(['polaroid', 'white', 'film']);
/** The cutout and the hard shadow of a frame cut to a shape: its path, grown and offset, behind the picture. */
function ShapeSilhouette({ frame, mask, look }: { frame: BookFrame; mask: BookShapeId; look: PhotoLook }) {
const { w, h } = frame;
const d = scalePath(SHAPE_PATHS[mask], w, h);
const rule = HOLED_SHAPES.has(mask) ? 'evenodd' : undefined;
const grow = look.cutout?.width ?? 0;
const path = (color: string, dx = 0, dy = 0) => (
<path
d={d}
transform={dx || dy ? `translate(${dx} ${dy})` : undefined}
fill={color}
fillRule={rule}
stroke={grow ? color : undefined}
strokeWidth={grow ? grow * 2 : undefined}
strokeLinejoin="round"
/>
);
return (
<svg
aria-hidden
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
style={{ position: 'absolute', inset: 0, overflow: 'visible', display: 'block' }}
>
{look.shadow && <g opacity={look.shadow.alpha}>{path(look.shadow.color, look.shadow.dx, look.shadow.dy)}</g>}
{look.cutout && path(look.cutout.color)}
</svg>
);
}
/** The border: a band along the inside of the picture's edge, following its shape. */
function BorderBand({
mask,
radius,
look,
width,
height,
}: {
mask: BookShapeId | null;
radius: number;
look: PhotoLook;
width: number;
height: number;
}) {
const border = look.border;
if (!border || width <= 0 || height <= 0) return null;
if (mask && mask !== 'rect') {
// Stroked twice as wide as the band and cut by the picture's own clip, so
// exactly the inner half shows and the band follows every curve.
return (
<svg
aria-hidden
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
>
<path
d={scalePath(SHAPE_PATHS[mask], width, height)}
fill="none"
stroke={border.color}
strokeWidth={border.width * 2}
strokeLinejoin="round"
/>
</svg>
);
}
return (
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
borderStyle: 'solid',
borderWidth: `${border.width}mm`,
borderColor: border.color,
borderRadius: radius ? `${radius}mm` : undefined,
boxSizing: 'border-box',
}}
/>
);
}
/** The picture itself, zoomed about its focal point and mirrored where the look says so. */
function Picture({
el,
photoId,
look,
big,
print,
filterId,
}: {
el: BookPhotoElement;
photoId: number;
look: PhotoLook;
big: boolean;
print: boolean;
filterId: string | null;
}) {
const zoom = zoomPlacement(look.zoom, el.focalX, el.focalY);
const img = (
<img
src={photoSrc(photoId, big)}
alt=""
draggable={false}
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: zoom?.size ?? '100%',
height: zoom?.size ?? '100%',
transform: zoom?.transform,
objectFit: el.fit,
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
filter: pictureFilter(PHOTO_FILTERS[el.filter], look, filterId),
display: 'block',
}}
/>
);
if (!look.flipX) return img;
// The picture is mirrored inside a box the size of the frame, so the part
// the crop shows stays the same part and the mask is not mirrored with it.
return <div style={{ position: 'absolute', inset: 0, transform: 'scaleX(-1)' }}>{img}</div>;
}
/** What an empty frame shows while editing: a hatch, and where there is room, the instruction. */
function Hatch({ clipped, label, labelSize }: { clipped: boolean; label: ReactNode; labelSize: number }) {
return (
<div
style={{
position: 'absolute',
inset: 0,
/*
* Opaque, and that is the point.
*
* The hatch used to be laid over nothing, so an empty frame showed
* whatever was behind it and every attempt to arrange a shape *under*
* one looked like it had done nothing: the frame appeared to sit at the
* bottom of the stack no matter where it was, right up until a
* photograph landed in it and it suddenly covered things. A frame is a
* promise that a picture goes here, and a picture is not see-through.
*/
background: 'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable: paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2mm',
}}
>
{label && (
<span
style={{
fontFamily: fontStack('sans'),
fontSize: `${labelSize}mm`,
fontWeight: 600,
letterSpacing: '0.16em',
lineHeight: 1.5,
textAlign: 'center',
textTransform: 'uppercase',
color: 'rgba(0,0,0,.34)',
whiteSpace: 'pre-line',
userSelect: 'none',
}}
>
{label}
</span>
)}
</div>
);
}
export function PhotoView({
el,
big,
print,
dropLabel,
frameStyle,
}: {
el: BookPhotoElement;
big: boolean;
print: boolean;
dropLabel: string;
/** The positioned box every element sits in, from SpreadView. */
frameStyle: CSSProperties;
}) {
const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none;
const side = Math.min(el.frame.w, el.frame.h);
// Rounded to a hundredth of a millimetre, the same precision the document
// stores. Without it a fraction of the frame lands in the CSS as
// `10.200000000000001mm`, which is not wrong but is float noise in a
// stylesheet that a person may well end up reading.
const pad = round2(deco.pad * side);
const bottom = round2(deco.bottom * side);
const clipId = usePaintId(`c-${el.id}`);
const filterRef = usePaintId(`f-${el.id}`);
// The shape the picture is cut to; a rect mask is no mask at all.
const mask = el.mask && el.mask !== 'rect' ? el.mask : null;
// The cut as a unit path: the mask, its cut edge, or a cut edge on the rectangle (shapeOutline.ts).
const clip = photoClip(el, el.frame.w - pad * 2, el.frame.h - pad - (bottom || pad));
const clipped = clip !== null;
// An empty frame wears the frame style but none of the look: a border or a
// shadow around a hatch would read as part of the picture to come.
const empty = el.photoId == null;
const look = photoLookOf(empty ? {} : el);
const matrices = svgFilterMatrices(look);
const filterId = matrices.length ? filterRef : null;
const card = CARDS.has(el.frameStyle);
/*
* An empty frame is a template's promise: it says where a picture goes before
* anyone has chosen which, and it says it *in the frame it will wear*.
*
* This used to render on its own, ignoring frameStyle entirely, so a Polaroid
* placeholder was an ordinary dashed rectangle until a photograph landed in
* it and the chin appeared from nowhere. The two branches have been folded
* together: the surround is drawn once, and only what goes inside it differs.
*/
// In the printed book an unfilled frame is nothing at all: a hatch and an
// instruction on a page someone paid to have bound would be a defect.
if (empty && print) return null;
// The label is sized in millimetres so it scales with the zoom exactly as the
// page does, and it steps aside on a frame too small to hold it.
const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085));
const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34;
// A photograph lying on a page, rather than printed into it. Soft and low: a
// hard shadow reads as a UI card, not as paper. The look's own cutout and
// hard shadow join it when the frame's outline is a rectangle.
const soft = el.frameStyle === 'shadow' || el.frameStyle === 'polaroid' ? '0 1.2mm 3mm rgba(0,0,0,.22)' : null;
const boxShadows = [
...(clipped && !card ? [] : silhouetteShadows(look.shadow, look.cutout)),
...(soft ? [soft] : []),
];
const picture = (
<div
style={{
position: 'absolute',
left: `${pad}mm`,
top: `${pad}mm`,
right: `${pad}mm`,
bottom: `${bottom || pad}mm`,
overflow: 'hidden',
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
clipPath: clipped ? `url(#${clipId})` : undefined,
}}
>
{el.photoId == null ? (
<Hatch clipped={clipped} label={roomy && dropLabel} labelSize={labelSize} />
) : (
<Picture el={el} photoId={el.photoId} look={look} big={big} print={print} filterId={filterId} />
)}
{!empty && (
<BorderBand
mask={el.mask}
radius={el.radius}
look={look}
width={el.frame.w - pad * 2}
height={el.frame.h - pad - (bottom || pad)}
/>
)}
</div>
);
return (
<div
style={{
...frameStyle,
background:
el.frameStyle === 'polaroid' || el.frameStyle === 'white'
? '#ffffff'
: el.frameStyle === 'film'
? '#141414'
: undefined,
borderRadius: !card && !clipped && el.radius ? `${el.radius}mm` : undefined,
boxShadow: boxShadows.length ? boxShadows.join(', ') : undefined,
}}
>
{mask && !card && !empty && (look.shadow || look.cutout) && (
<ShapeSilhouette frame={el.frame} mask={mask} look={look} />
)}
{(clip || filterId) && (
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
<defs>
{clip && (
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d={clip} />
</clipPath>
)}
{filterId && (
<filter id={filterId} colorInterpolationFilters="sRGB">
{matrices.map((values, i) => (
<feColorMatrix key={i} type="matrix" values={values} />
))}
</filter>
)}
</defs>
</svg>
)}
{picture}
{/* Sprocket holes. Drawn as a repeating gradient rather than as elements
so the count follows the width instead of being fixed at a size that
only looks right on one frame. */}
{el.frameStyle === 'film' &&
[0, 1].map((row) => (
<div
key={row}
style={{
position: 'absolute',
left: `${pad * 0.35}mm`,
right: `${pad * 0.35}mm`,
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
height: `${pad * 0.42}mm`,
background:
'repeating-linear-gradient(90deg,' +
` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
}}
/>
))}
{/*
Two strips, one over each top corner, each centred *on* its corner so it
overhangs the picture evenly on both sides, which is what a piece of
tape does. Offsetting by a fraction of the strip instead left them
sitting beside the corners rather than across them.
*/}
{el.frameStyle === 'tape' &&
([-1, 1] as const).map((dir) => {
const tapeW = side * 0.26;
const tapeH = side * 0.075;
return (
<div
key={dir}
style={{
position: 'absolute',
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
top: `${round2(-tapeH / 2)}mm`,
width: `${round2(tapeW)}mm`,
height: `${round2(tapeH)}mm`,
background: 'rgba(236,228,206,.82)',
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
// Away from the corner on each side: anticlockwise on the left,
// clockwise on the right, so the pair reads as symmetric.
transform: `rotate(${dir * 45}deg)`,
}}
/>
);
})}
</div>
);
}

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