Compare commits

..

2 Commits

Author SHA1 Message Date
Maurice 0c3cd63e34 fix(assistant): the tour package keeps to two prompt lines
The registry refuses a package with more than two prompt lines at load, which
failed every suite that boots the assistant. The planning line now holds the
tools, the profiles and the hike rules.
2026-10-23 20:47:32 +02:00
Maurice f5ad78d988 feat(assistant): plan and save every tour type, bike tours, runs, paddle and ski tours included
The assistant can now plan and save every type the Tours tab plans. A bike
tour or a run is proposed and created through the hike's own plan and save
(propose_tour, create_tour), routed on the server with the costing of its
profile, now kept once in shared and read by the planner as well. A paddle
or ski tour planned with plan_paddle_route or plan_ski_route is saved with
create_paddle_tour or create_ski_tour, and "Plan this" takes those plans,
bike tours and runs too. Every create has its card and its way back.
2026-10-23 20:12:43 +02:00
1266 changed files with 7017 additions and 94132 deletions
-51
View File
@@ -53,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
-3
View File
@@ -112,9 +112,6 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- 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 }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- 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
# here. Defaults to on.
# 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
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
-67
View File
@@ -1,67 +0,0 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+3 -3
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-head-close'),
target: p => p.locator('.st-back'),
},
],
},
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
prepare: async p => {
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p)
},
+1 -28
View File
@@ -47,41 +47,14 @@
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -108,7 +81,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25",
"zod": "^4.6.7",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
+12 -12
View File
@@ -1,26 +1,26 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 385,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 854,
"no-console": 88,
"complexity": 222,
"complexity": 226,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
"trek/no-swallowed-catch": 131,
"max-params": 33,
"trek/store-without-selector": 67,
"react-hooks/refs": 556,
"react-hooks/set-state-in-effect": 194,
"react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"max-lines-per-function": 132,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 45,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
@@ -34,8 +34,8 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1931,
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+1 -9
View File
@@ -69,15 +69,6 @@
"desktop": ["src/components/Assistant/AssistantWindow.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",
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
@@ -601,6 +592,7 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [
"src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts"
],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
+2
View File
@@ -1123,6 +1123,8 @@
"src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.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.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx",
+5 -7
View File
@@ -117,6 +117,11 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
},
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}',
@@ -139,13 +144,6 @@ export const DYNAMIC_ALLOWED = [
'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_:-]+)+$/;
+1 -1
View File
@@ -26,7 +26,7 @@
"packing.ts": 6,
"pdf.ts": 1,
"perm.ts": 4,
"places.ts": 9,
"places.ts": 10,
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 36,
+1 -1
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1,
+1
View File
@@ -116,6 +116,7 @@
"src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts",
+6 -3
View File
@@ -18,6 +18,7 @@
"components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1,
@@ -51,12 +52,13 @@
"components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4,
"components/Studio/SpreadView.tsx": 4,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/SpreadView.tsx": 8,
"components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
@@ -71,6 +73,7 @@
"components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3,
+2 -5
View File
@@ -1,7 +1,4 @@
{
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
"pages/tripPlanner/PlanMapStage.tsx": 4,
"mobile/components/trek3d/MascotClip.tsx": 1
}
+5 -5
View File
@@ -8,23 +8,23 @@
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"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/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/TripPlannerPage.tsx": 1131,
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1936
"src/styles/studio.css": 2087
}
+4 -1
View File
@@ -51,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3,
"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/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -82,6 +82,8 @@
"src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
@@ -96,6 +98,7 @@
"src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
+5 -4
View File
@@ -100,13 +100,13 @@
"components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5,
"components/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -132,6 +132,7 @@
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2,
@@ -251,7 +252,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"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/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
+1 -2
View File
@@ -40,8 +40,7 @@
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 2,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"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'))
// 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 MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
// 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
@@ -411,10 +410,10 @@ export default function App() {
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{!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. */}
{!hideAuthedWidgets && isAuthenticated && (
{/* The phone has its own assistant screen inside the trip. */}
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
<Suspense fallback={null}><AssistantHost /></Suspense>
</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);
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 AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse,
type AssistantScope,
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
* scope, which also reaches a journey's chat in TREK Studio and the home chat
* outside any one trip. Threads, turns, decisions, undo and stop exist under
* all three; files, found places, proposals and sharing belong to a trip and
* take its id.
* scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
* decisions, undo and stop exist under both; files, found places, proposals
* and sharing belong to a trip and take its id.
*/
export type AssistantTarget = number | AssistantScope;
function scopeBase(target: AssistantTarget): string {
if (typeof target === 'number') return tripBase(target);
if (target.kind === 'home') return '/assistant';
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
}
@@ -146,13 +143,6 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
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.
takeProposal: (
tripId: number,
@@ -3,7 +3,6 @@ import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-reac
import { type ReactNode, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import PlaceAvatar from '../shared/PlaceAvatar';
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 { take, busy, accept, canAttach } = useAssistantAttach(tripId);
const fileInput = useRef<HTMLInputElement>(null);
// The home chat is about no one trip, so its placeholder names none.
const fallback =
tripId === HOME_CHAT_KEY
? t('assistant.home.placeholder')
: t('assistant.composer.placeholder', { trip: tripTitle });
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
const placeholder = useTypewriterPlaceholder(
suggestions.map((s) => s.prompt),
fallback,
@@ -1,17 +1,5 @@
import {
Ellipsis,
History,
House,
Lightbulb,
LightbulbOff,
Luggage,
NotebookPen,
Plus,
Settings,
Trash2,
X,
} from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
@@ -24,9 +12,7 @@ import Tooltip from '../shared/Tooltip';
import { PopoverListPanel } from '../shared/dialogParts';
import { usePopoverList } from '../shared/usePopoverList';
import AssistantMemoryDialog from './AssistantMemoryDialog';
import AssistantTripPicker from './AssistantTripPicker';
import { AI_SETTINGS_PATH } from './assistantParts';
import type { AssistantChatTrip } from './assistantViewContext';
import { useAssistantHintsSetting } from './useAssistantHints';
import { useAssistantLiveStep } from './useAssistantLiveStep';
import type { useAssistantThread } from './useAssistantThread';
@@ -43,16 +29,6 @@ interface AssistantHeaderProps {
onClose: () => void;
/** Moves the window: spread on the title, which is the part of the header to grab. */
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;
* 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({
tripId,
thread,
tripTitle,
onClose,
dragProps,
tripSwitch,
}: AssistantHeaderProps) {
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
const { t, locale } = useTranslation();
const live = useAssistantLiveStep(tripId);
const memory = useTripMemory(tripId);
@@ -79,7 +48,6 @@ export default function AssistantHeader({
const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate();
const menu = useContextMenu();
const moreRef = useRef<HTMLButtonElement>(null);
const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
@@ -96,14 +64,6 @@ export default function AssistantHeader({
run();
};
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
const closeMenu = () => {
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
menu.close();
if (fromMenu) moreRef.current?.focus();
};
const openMenu = (e: MouseEvent) =>
menu.open(
e,
@@ -162,7 +122,6 @@ export default function AssistantHeader({
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
{tripChat && (
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
<button
@@ -196,12 +155,10 @@ export default function AssistantHeader({
</Tooltip>
<Tooltip label={t('assistant.window.menu')}>
<button
ref={moreRef}
type="button"
onClick={openMenu}
aria-label={t('assistant.window.menu')}
aria-haspopup="menu"
aria-expanded={menu.menu !== null}
className={ICON_BUTTON}
>
<Ellipsis size={14} aria-hidden="true" />
@@ -229,7 +186,6 @@ export default function AssistantHeader({
icon={null}
label={th.title}
active={th.id === thread.threadId}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-shrink-0 text-caption text-content-faint">
@@ -246,7 +202,7 @@ export default function AssistantHeader({
)}
</PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={closeMenu} />
<ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog
isOpen={confirming}
@@ -262,49 +218,3 @@ export default function AssistantHeader({
</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
* keeps going from page to page. Inside a trip it is about that trip, outside
* one it is the home chat about all of them (AssistantWindow); what a
* keeps going from page to page. Inside a trip it is about that trip; what a
* 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
* 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());
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', () => {
@@ -15,7 +15,7 @@ export const LAUNCHER_SIZE = 48;
/**
* 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
* 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.
* 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.
@@ -35,11 +35,11 @@ export default function AssistantLauncher({
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
const toggle = useAssistantStore((s) => s.toggle);
// An answer still being written in a closed window: the floating button breathes until it is opened. A
// trip's chat and the home chat count: an answer still running in the Studio is not one this window opens on.
// An answer still being written in a closed window: the floating button breathes until it is opened. Only a
// trip's chat counts: an answer still running in the Studio is not one this window opens on.
const busy = useAssistantStore((s) =>
!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
);
const keys = useAssistantShortcutKeys();
@@ -1,5 +1,5 @@
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 { 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']>;
/** 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>
</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} />
<TourButton place={place} strip={strip} tall={tall} />
</div>
</li>
);
@@ -21,7 +21,6 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -106,7 +105,6 @@ afterEach(() => {
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks();
});
@@ -240,22 +238,3 @@ describe('AssistantPlaceStrip', () => {
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,
onRevealMap,
bookVersion,
onOpenTripChat,
}: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says.
@@ -42,9 +41,8 @@ export default function AssistantSurface({
onFocusDay,
onRevealMap,
bookVersion,
onOpenTripChat,
}),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
@@ -1,5 +1,4 @@
import { ChevronRight, Luggage } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
@@ -7,19 +6,12 @@ import { Spinner } from '../shared/Spinner';
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
* head: that chat, its history and its changes belong to the trip. The
* running and the next trip lead. The list opens before the trips are there,
* so the focus goes onto the first one once they come.
* Outside a trip the assistant first asks which one: the chat, its history
* and its changes all belong to one trip. The running and the next trip lead.
*/
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation();
const trips = useAssistantTripPicker();
const first = useRef<HTMLButtonElement>(null);
useEffect(() => {
first.current?.focus({ preventScroll: true });
}, [trips]);
if (trips === null) {
return (
@@ -39,10 +31,9 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip, i) => (
{trips.map((trip) => (
<li key={trip.id}>
<button
ref={i === 0 ? first : undefined}
type="button"
onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
@@ -28,15 +28,20 @@ export default function AssistantWelcome({
}) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
const scope = useAssistantScope().kind;
const book = scope === 'journey';
// The Studio's chat is about a book, not a plan.
const book = useAssistantScope().kind === 'journey';
const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
<EmptyState
scene="chat"
size={72}
compact
surface="var(--bg-elevated)"
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
/>
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
@@ -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({
suggestions,
layout,
@@ -1,9 +1,8 @@
/**
* FE-AI-WIN-001 to FE-AI-WIN-016
* FE-AI-WIN-001 to FE-AI-WIN-012
*
* 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
* and back, closing that keeps the chat, and
* opens from, the trip picker outside a trip, closing that keeps the chat, and
* 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
* offers the notes its turns read. The geometry is
@@ -37,14 +36,12 @@ vi.mock('./AssistantSurfaceLazy', () => ({
density,
onFocusDay,
onOpen,
onOpenTripChat,
}: {
tripId: number;
tripTitle: string;
density?: string;
onFocusDay?: (dayId: number) => void;
onOpen?: (target: EntityTarget) => void;
onOpenTripChat?: (trip: { id: number; title: string }, question: string | null) => void;
}) => (
<div>
<p>
@@ -69,11 +66,6 @@ vi.mock('./AssistantSurfaceLazy', () => ({
</button>
</>
)}
{onOpenTripChat && (
<button type="button" onClick={() => onOpenTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
go on in Lisbon
</button>
)}
<textarea aria-label="composer" />
</div>
),
@@ -144,36 +136,12 @@ describe('AssistantWindow', () => {
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 });
render(<AssistantWindow trip={null} />);
expect(screen.getByText('chat 0 Your travels')).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' }));
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
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', () => {
@@ -238,34 +206,6 @@ describe('AssistantWindow', () => {
expect(useAssistantStore.getState().window.open).toBe(true);
});
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(more).toHaveFocus();
expect(more).toHaveAttribute('aria-expanded', 'false');
// From the button the next Escape is the window's again.
fireEvent.keyDown(more, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
const button = screen.getByRole('button', { name: 'Chat about one trip' });
fireEvent.click(button);
// The list's own row, not the chat's "go on in Lisbon" beside it.
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
await waitFor(() => expect(lisbon).toHaveFocus());
fireEvent.keyDown(lisbon, { key: 'Escape' });
expect(button).toHaveFocus();
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false });
const onFocusDay = vi.fn();
@@ -298,10 +238,6 @@ describe('AssistantWindow', () => {
<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(screen.getByRole('button', { name: 'open tab' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
@@ -1,21 +1,20 @@
import './assistant.css';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantFrameEdges from './AssistantFrameEdges';
import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
import { plannerSearchOf } from './assistantShowOnMap';
import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes';
import { useAssistantHome } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread';
@@ -31,18 +30,17 @@ import { useAssistantThread } from './useAssistantThread';
* an answer is still being written. Escape stops an answer first, then
* 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
* connection this user can use.
*/
type WindowTrip = AssistantChatTrip;
interface WindowTrip {
id: number;
title: string;
}
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;
/** Opens what a card or an applied change points at, from the trip on screen. */
onOpen?: (target: EntityTarget) => void;
@@ -72,16 +70,12 @@ function ChatWindow({
onOpen,
onFocusDay,
onRevealMap,
onOpenTripChat,
tripSwitch,
}: {
trip: WindowTrip;
hidden: boolean;
onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void;
onRevealMap?: () => void;
onOpenTripChat?: (trip: WindowTrip, question: string | null) => void;
tripSwitch?: Parameters<typeof AssistantHeader>[0]['tripSwitch'];
}) {
const { t } = useTranslation();
const thread = useAssistantThread(trip.id);
@@ -156,7 +150,6 @@ function ChatWindow({
thread={thread}
tripTitle={trip.title}
dragProps={frame.handleProps}
tripSwitch={tripSwitch}
// Closing only hides the window: an answer being written keeps coming in,
// and the floating button says so until it is opened again.
onClose={() => setMinimized(true)}
@@ -169,7 +162,6 @@ function ChatWindow({
onOpen={onOpen}
onFocusDay={onFocusDay}
onRevealMap={onRevealMap}
onOpenTripChat={onOpenTripChat}
/>
</div>
</section>
@@ -185,21 +177,19 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
const home = useAssistantHome(setPicked);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
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) {
setWasOpen(open);
if (!open) setPicked(null);
}
const current = trip ?? picked ?? home.chat;
const atHome = current.id === HOME_CHAT_KEY;
const current = trip ?? picked;
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
const openPicked = useCallback(
(target: EntityTarget) => {
@@ -208,14 +198,6 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
},
[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.
const openOnScreen = useCallback(
(target: EntityTarget) => {
@@ -227,27 +209,52 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
if (!available) return 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;
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 (
<>
<ChatWindow
trip={current}
hidden={minimized}
onOpen={openTarget}
onOpen={onScreen ? onOpen && openOnScreen : openPicked}
// Only the planner on screen has days to select and a map to bring back.
onFocusDay={onScreen ? onFocusDay : 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 && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} 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 },
too_many_requests: { messageKey: 'assistant.error.rateLimited', 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 },
generic: {
messageKey: 'assistant.error.generic',
@@ -225,17 +224,6 @@ const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
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,
* then what fits the trip's phase. The label is also the prompt, so a tapped
@@ -138,9 +138,6 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
'plan_ski_route',
'prepare_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. */
@@ -462,5 +459,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
difficulty: { labelKey: 'assistant.change.label.difficulty' },
track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
};
@@ -34,17 +34,6 @@ export interface AssistantView {
* version the trial ran on.
*/
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);
@@ -1,5 +1,5 @@
/**
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
*
* The pure side of the book's page card: a page named as the Studio names
* it, a layout and a finding by their words, the findings worth a look, the
@@ -8,7 +8,7 @@
* the card carries it and the book is still at the trial's version), and
* where an applied change opens the Studio.
*/
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
@@ -67,9 +67,6 @@ describe('bookCardModel', () => {
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
'studio.templates.name.postcardHouses'
);
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
@@ -95,15 +92,6 @@ describe('bookCardModel', () => {
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
});
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
for (const code of BOOK_FINDING_CODES) {
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
}
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
expect(BOOK_FINDING_CODES).toContain(code);
}
});
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
const doc = book();
const [edit] = pageViewOf(card(), doc, 'pending');
@@ -10,7 +10,6 @@ import {
type BookSpread,
findBookGrid,
findBookTemplate,
findDecoratedTemplate,
setBookCover,
} from '@trek/shared';
@@ -58,15 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
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' },
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
@@ -92,7 +82,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
}
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
const template = findBookTemplate(layout.id);
return template ? t(template.labelKey) : null;
}
@@ -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.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookAssets' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
},
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';
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
verbs: {
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
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' },
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
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' },
bold: { labelKey: 'assistant.card.bookDesign.style.bold' },
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;
@@ -28,7 +28,6 @@ import { BUDGET_LOOK } from './budget/budgetLook';
import type { CardLook, EntityLook, StepLook, VerbLook } from './cardTypes';
import { COLLAB_LOOK } from './collab/collabLook';
import { COLLECTION_LOOK } from './collection/collectionLook';
import { HOME_LOOK } from './home/homeLook';
import { JOURNEY_LOOK } from './journey/journeyLook';
import { PACKING_LOOK } from './packing/packingLook';
import { ROADTRIP_LOOK } from './roadtrip/roadtripLook';
@@ -171,7 +170,6 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
...PACKING_LOOK.steps,
...BOOK_LOOK.steps,
...BOOK_DESIGN_LOOK.steps,
...HOME_LOOK.steps,
};
/** 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,
...BOOK_LOOK.cards,
...BOOK_DESIGN_LOOK.cards,
...HOME_LOOK.cards,
};
/** 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 { COLLAB_VIEWS } from './collab/collabViews';
import { COLLECTION_VIEWS } from './collection/collectionViews';
import { HOME_VIEWS } from './home/homeViews';
import { JOURNEY_VIEWS } from './journey/journeyViews';
import { PACKING_VIEWS } from './packing/packingViews';
import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews';
@@ -34,7 +33,6 @@ export const CARD_VIEWS: { readonly [K in TrekCardKind]: CardView<K> } = {
...PACKING_VIEWS,
...BOOK_VIEWS,
...BOOK_DESIGN_VIEWS,
...HOME_VIEWS,
};
/** 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.
*/
/** The band behind a group's head: its colour faint over the card's grey, the grey alone without one. */
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.
*/
/** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
export function ChecklistHead({
color,
name,
@@ -47,17 +38,14 @@ export function ChecklistHead({
lead?: ReactNode;
}) {
return (
<div
className="mt-1.5 flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-1.5"
style={{ background: bandOf(color) }}
>
<div className="flex min-w-0 items-center gap-2 pt-1">
{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}
</bdi>
{(badge || people.length > 0 || removed.length > 0) && (
<span className="flex flex-none items-center gap-1.5">
{badge}
{badge}
{(people.length > 0 || removed.length > 0) && (
<span className="ms-auto flex flex-none items-center gap-1.5">
<PeopleStack people={people} />
{/* The people a change takes off, faded; the card says "No longer" in words under the head. */}
{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. */
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 }) {
const { t } = useTranslation();
return (
<span
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}
</span>
@@ -231,7 +231,7 @@ function BagHead({
}) {
const { t } = useTranslation();
if (!bag) {
const badge = <GroupCount count={group.count} />;
const badge = inChange ? undefined : <GroupCount count={group.count} />;
const lead = <BagSwatch bag={undefined} size="head" />;
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
}
@@ -298,7 +298,7 @@ export function PackingGroup({
<ChecklistHead
color={packingColorOf(key.slot, name)}
name={name}
badge={<GroupCount count={group.count} />}
badge={inChange ? undefined : <GroupCount count={group.count} />}
people={group.assignees}
removed={group.assigneesRemoved}
/>
@@ -1,23 +1,5 @@
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import {
ArrowLeftRight,
ChartNoAxesColumn,
Circle,
CircleCheck,
CircleSlash,
Copy,
Footprints,
History,
Luggage,
MapPinMinus,
MapPinPlus,
Palette,
Repeat,
Route,
Snowflake,
TriangleAlert,
Waves,
} from 'lucide-react';
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes';
@@ -113,16 +95,6 @@ export const TOUR_LOOK = {
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
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: {},
steps: {
@@ -135,14 +107,6 @@ export const TOUR_LOOK = {
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
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: {
tour: {
@@ -151,8 +151,6 @@ describe('tourRouteModel', () => {
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);
// 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", () => {
@@ -228,16 +228,14 @@ export function routePoints(card: TourRouteCard): [number, number][] {
/**
* Whether "Plan this" can take the proposal: a city walk, a hike, a bike
* tour or a run whose way was planned, or a paddle or ski plan whose every
* 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.
* leg was planned. A saved tour and a held change cannot.
*/
export function canTake(card: TourRouteCard): boolean {
if (card.source === 'plan') {
const whole = card.warnings.every((warning) => warning.kind !== 'legs_failed');
return (card.activity === 'kayak' || card.activity === 'ski') && card.routed && whole;
}
if (card.source !== 'proposal' || card.placeId !== null) return false;
if (card.source !== 'proposal') return false;
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
}
@@ -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 { useTranslation } from '../../i18n';
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
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[] = [
'.pdf',
'.eml',
@@ -16,7 +16,6 @@ const ATTACH_EXTENSIONS: readonly string[] = [
'.htm',
'.txt',
...LLM_PHOTO_EXTENSIONS,
...ASSISTANT_TOUR_FILE_EXTENSIONS,
];
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
* 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.
@@ -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 { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useOpenTourStore } from '../../store/openTourStore';
import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
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
* 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.
*
* 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() {
const { tripId, onRevealMap } = useAssistantView();
@@ -65,8 +60,6 @@ export function useAssistantPlaceStrip() {
);
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
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 focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
@@ -92,14 +85,6 @@ export function useAssistantPlaceStrip() {
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. */
const show = point.show;
@@ -111,5 +96,5 @@ export function useAssistantPlaceStrip() {
[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 { useTripStore } from '../../store/tripStore';
import { localIsoDate } from '../../utils/localDate';
import { HOME_SUGGESTIONS, suggestionsFor, tripPhase } from './assistantModel';
import { suggestionsFor, tripPhase } from './assistantModel';
import { useAssistantFocus } from './useAssistantFocus';
export interface ReadySuggestion {
@@ -21,9 +21,7 @@ export interface ReadySuggestion {
* (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
* 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
* 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.
* and its rights are the journey's role, as the Studio itself reads it.
*/
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
const { t } = useTranslation();
@@ -37,11 +35,9 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
const journeyRole = useJourneyStore((s) =>
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) : home || (journeyRole ?? 'owner') !== 'viewer';
const canEdit = tripChat ? !!trip && can('place_edit', trip) : (journeyRole ?? 'owner') !== 'viewer';
const suggestions = useMemo((): ReadySuggestion[] => {
if (home) return HOME_SUGGESTIONS.map((s) => ({ id: s.id, prompt: t(s.labelKey) }));
if (!tripChat) return [];
const phase = trip
? tripPhase({
@@ -57,7 +53,7 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
place: kind === 'place' ? label : null,
day: kind === 'day' ? label : null,
}).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 };
}
@@ -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.
vi.mock('./VideoPlayer', () => ({
@@ -17,7 +17,6 @@ vi.mock('../../api/websocket', () => ({
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import PhotoLightbox from './PhotoLightbox';
const samplePhotos = [
@@ -248,82 +247,4 @@ describe('PhotoLightbox', () => {
expect(img.style.transform).toContain('scale(2)');
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 { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip'
import { isRtlLanguage, useTranslation } from '../../i18n'
import { useTranslation } from '../../i18n'
interface LightboxPhoto {
id: string
src: string
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
asset_id?: string | null
owner_id?: number | null
@@ -21,8 +19,6 @@ interface Props {
photos: LightboxPhoto[]
startIndex?: number
onClose: () => void
/** What the dialog is called for assistive tech. */
label?: string
}
/** The round glass buttons of the top bar. */
@@ -32,21 +28,10 @@ const ROUND_BTN: React.CSSProperties = {
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 touchStart = useRef<{ x: number; y: number } | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
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 { t } = useTranslation()
const photo = photos[idx]
// Zoom starts over on every photo (#1484).
@@ -61,12 +46,12 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') (rtl ? next : prev)()
if (e.key === 'ArrowRight') (rtl ? prev : next)()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('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
// 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
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0 !== rtl) next()
if (dx < 0) next()
else prev()
}
touchStart.current = null
@@ -104,13 +89,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={{
outline: 'none',
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
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) */}
{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={{
position: 'absolute', insetInlineStart: 16, zIndex: 5,
<button type="button" onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={22} className="rtl:rotate-180" />
<ChevronLeft size={22} />
</button>
)}
@@ -200,33 +179,27 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
{/* Next button */}
{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={{
position: 'absolute', insetInlineEnd: 16, zIndex: 5,
<button type="button" onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={22} className="rtl:rotate-180" />
<ChevronRight size={22} />
</button>
)}
{/* Caption — bottom center overlay */}
{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={{
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)',
padding: '6px 14px', borderRadius: 10, pointerEvents: 'auto',
}}>
{photo.captionHref ? (
<a href={photo.captionHref} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }} className="hover:underline">
{photo.caption}
</a>
) : photo.caption}
</p>
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
</div>
)}
</div>
@@ -15,6 +15,8 @@ import {
calculateAlternatives,
avoidedClasses,
routeEngineFor,
sameRoad,
furthestFrom,
osrmLegSegment,
} from './RouteCalculator'
@@ -1202,6 +1204,37 @@ describe('a trip that avoids something', () => {
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 () => {
server.use(
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 { countRoute } from './routeUsageCounter'
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
// 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
/**
* 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.
@@ -1068,3 +1103,87 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
const furthest = furthestFrom(line, reference)
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]
}
@@ -1,4 +1,4 @@
// FE-PACKPOP-001 to FE-PACKPOP-009
// FE-PACKPOP-001 to FE-PACKPOP-008
import { describe, it, expect, vi } from 'vitest'
import { useRef } from 'react'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -56,15 +56,6 @@ describe('PopoverItem', () => {
expect(danger.style.background).toBe('none')
})
it('FE-PACKPOP-009: the entry open now is said to be the current one, not only tinted', () => {
render(<>
<PopoverItem icon={null} label="Temples" active current onClick={() => {}} />
<PopoverItem icon={null} label="Food" onClick={() => {}} />
</>)
expect(screen.getByRole('button', { name: 'Temples' })).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('button', { name: 'Food' })).not.toHaveAttribute('aria-current')
})
it('FE-PACKPOP-004: the composer button fades while there is nothing to add', () => {
expect(composerConfirm(false)).toMatchObject({ opacity: 0.35, cursor: 'default' })
expect(composerConfirm(true)).toMatchObject({ opacity: 1, cursor: 'pointer' })
@@ -7,8 +7,6 @@ interface PopoverItemProps {
label: string
onClick: () => void
active?: boolean
/** The entry is the one open now (a chat in a history list), said so to a screen reader too. */
current?: boolean
danger?: boolean
/** A secondary line, such as Add bag, in the faint text colour. */
muted?: boolean
@@ -16,12 +14,11 @@ interface PopoverItemProps {
trailing?: ReactNode
}
export function PopoverItem({ icon, label, onClick, active = false, current = false, danger = false, muted = false, trailing }: PopoverItemProps) {
export function PopoverItem({ icon, label, onClick, active = false, danger = false, muted = false, trailing }: PopoverItemProps) {
const tone = danger ? '#ef4444' : muted ? 'var(--text-faint)' : 'var(--text-primary)'
return (
<button type="button"
onClick={onClick}
aria-current={current || undefined}
style={{
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '8px 10px', borderRadius: 9, border: 'none', cursor: 'pointer',
@@ -110,8 +110,8 @@ describe('PlaceDetailsColumn', () => {
})
})
it('FE-PDC-004: renders author and licence under the Commons picture in play', async () => {
renderColumn({ selectedImageUrl: COMMONS_PHOTO.url })
it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
renderColumn()
const credit = await screen.findByText('Alice')
// 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 () => {
placeEnrichment.mockResolvedValue({ photos: [GOOGLE_PHOTO], description: null, facts: [] })
renderColumn({ selectedImageUrl: GOOGLE_PHOTO.url })
renderColumn()
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 () => {
@@ -613,24 +611,17 @@ describe('PlaceDetailsColumn — picture layout', () => {
withPhotos(3)
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')
expect(screen.queryByText('places.image.none')).not.toBeInTheDocument()
const tiles = screen.getAllByRole('button', { name: /places.details.pickImage/ })
fireEvent.mouseEnter(tiles[2])
expect(screen.getByText('Author 2')).toBeInTheDocument()
expect(screen.queryByText('Author 0')).not.toBeInTheDocument()
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 () => {
withPhotos(2)
renderColumn({ selectedImageUrl: '/api/maps/place-photo/p0/bytes' })
renderColumn()
expect(await screen.findByText('Author 0')).toBeInTheDocument()
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/ })
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 line = licence.parentElement?.parentElement as HTMLElement
expect(line).toHaveTextContent('Alice')
expect(line.textContent).not.toContain('·')
expect(licence.closest('p')).toHaveTextContent('Alice')
expect(licence.closest('p')?.textContent).not.toContain('·')
fireEvent.click(tile)
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()
})
})
/**
* 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,
Bike,
Building2,
Check,
ChefHat,
ChevronDown,
ChevronUp,
@@ -32,22 +33,22 @@ import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts'
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
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 {
selection: PlaceDetailsSelection | null
/** Currently chosen hero image, so the picked tile can show as picked. */
selectedImageUrl?: string | null
selectedImageUrl?: string
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
/** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */
onHours?: (hours: PlaceHours) => void
@@ -74,6 +75,47 @@ interface PlaceDetailsColumnProps {
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'
/**
@@ -119,7 +161,6 @@ export default function PlaceDetailsColumn({
locale = 'en-US',
fluid = false,
variant = 'sheet',
image,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const dialog = variant === 'dialog'
@@ -142,8 +183,9 @@ export default function PlaceDetailsColumn({
return
}
const cached = readCachedEnrichment(selection, language)
const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
if (cached) {
enrichmentCache.set(selectionKey, cached)
setData(cached)
setState('ready')
return
@@ -175,7 +217,8 @@ export default function PlaceDetailsColumn({
)
.then((result) => {
if (controller.signal.aborted) return
storeEnrichment(selection, language, result)
enrichmentCache.set(selectionKey, result)
writeSession(selectionKey, result)
setData(result)
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'}>
{/* 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
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. */}
@@ -298,6 +331,7 @@ export default function PlaceDetailsColumn({
{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} />
<OpeningHoursBlock
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. */
function PictureBlock({
/** The name to put under a picture. Google gives no author, so it gets its own name. */
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,
chosen,
selectedImageUrl,
onPickImage,
own,
dialog,
dialog = false,
t,
}: {
photos: PlacePhotoCandidate[]
chosen: ChosenImage
selectedImageUrl?: string
onPickImage: (url: string | null) => void
own?: PlaceImageOwn
dialog: boolean
dialog?: boolean
t: TranslationFn
}): React.ReactElement | null {
if (!offersPictures(photos, own)) return null
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} />
const [hovered, setHovered] = useState<string | null>(null)
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 (
<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>
)
}
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.
*
@@ -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.
@@ -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.
import { describe, expect, it } from 'vitest';
import type { Assignment, Place, TripFile } from '../../types';
import type { Assignment, Place } from '../../types';
import {
DEFAULT_FORM,
duplicateName,
@@ -155,18 +155,4 @@ describe('placeFormPayload', () => {
const payload = placeFormPayload(form, [], null, (lat, lng) => ({ at: [lat, lng] }));
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 { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice'
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
export interface PlaceFormData {
@@ -28,8 +27,7 @@ export interface PlaceFormData {
// Hero image picked from the detail column. Optional and absent from
// 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.
// Null drops the place's picture on save; undefined keeps it.
image_url?: string | null
image_url?: string
// 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
// 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
/** Files chosen before the place existed: uploaded once the save has given it an id. */
_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[],
assignment: Pick<Assignment, 'notes'> | null | undefined,
extra?: (lat: number | null, lng: number | null) => E,
pendingImage: PendingPlaceImage | null = null,
): PlaceFormPayload & E {
const lat = form.lat ? Number.parseFloat(form.lat) : 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,
...(extra ? extra(lat, lng) : {}),
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
_pendingImage: pendingImagePayload(pendingImage),
} as PlaceFormPayload & E
if (!assignment || (form.assignment_notes ?? '') === (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 () => {
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();
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 () => {
const user = userEvent.setup();
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' });
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 () => {
const user = userEvent.setup();
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(() => {});
await user.click(screen.getByRole('button', { name: /Attach/i }));
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 () => {
const user = userEvent.setup();
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' })] },
});
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 CollectionPicker from '../Collections/CollectionPicker'
import PlaceDetailsColumn from './PlaceDetailsColumn'
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia'
import { usePlaceImageField } from './usePlaceImageField'
import { useToast } from '../shared/Toast'
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 { PlaceContactFields } from './PlaceContactFields'
import { weekFromPeriods } from './placeHours'
@@ -43,6 +41,7 @@ import {
} from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
import { SoftPill, tintOf } from './planParts'
import { WHITE_BUTTON } from './placeDialogParts'
import { TimeSection } from './PlaceFormTimeSection'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
@@ -122,11 +121,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const {
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage,
adoptDescription, warnIfDuplicate,
} = 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
// in the head band. Null for a place typed by hand or taken from the saved list.
const [pickedSource, setPickedSource] = useState<string | null>(null)
@@ -362,7 +358,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
...(serviceStop
? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement }
: {}),
}), pendingImage)
}))
const saved = await onSave(payload)
// 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
@@ -446,7 +442,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handleRemoveFile: removeFile,
handlePaste,
pickImage,
image,
adoptDescription,
hasTimeError,
handleSubmit,
@@ -611,7 +606,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
selection={detailsSelection}
selectedImageUrl={form.image_url}
onPickImage={S.pickImage}
image={S.image}
onAdoptDescription={S.adoptDescription}
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
hasDescription={!!form.description.trim()}
@@ -885,7 +879,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
maxLength={2000}
/>
{!showDetails && <PlaceImageSection image={S.image} onPickUrl={S.pickImage} t={t} />}
{/* The link and the files side by side, as on a booking. */}
<div className={canUploadFiles ? GRID_2 : ''}>
<EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}>
@@ -1124,3 +1117,44 @@ function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
</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: {
details: vi.fn().mockResolvedValue({ place: 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 { mapsApi } from '../../api/client';
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
// ── Shared fixtures ───────────────────────────────────────────────────────────
@@ -102,8 +100,6 @@ beforeEach(() => {
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } });
vi.mocked(mapsApi.details).mockResolvedValue({ place: null });
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({ photos: [], facts: [], description: null } as never);
__clearEnrichmentCacheForTests();
});
// ── 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', () => {
render(<PlaceInspector {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Show photos' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => {
render(<PlaceInspector {...defaultProps} onUploadImage={vi.fn()} />);
// The place carries no image yet, so the avatar offers "Upload image".
expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-050: with no picture and nobody to ask for one the avatar stays a plain picture', () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => {
render(<PlaceInspector {...defaultProps} />);
expect(screen.queryByRole('button', { name: 'Show photos' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
});
// ── Track colour (#776) ──────────────────────────────────────────────────────
@@ -1422,23 +1415,15 @@ describe('PlaceInspector', () => {
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 () => {
seedStore(useAuthStore, { placesEnrichEnabled: false });
it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
const onUpdatePlace = vi.fn();
const onUploadImage = vi.fn(async () => {});
const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' });
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} />);
expect(screen.queryByRole('button', { name: 'Remove image' })).toBeNull();
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());
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
});
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 () => {
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue({
photos: [{
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:T.jpg', source: 'wikimedia',
}],
facts: [],
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-089: picking a file hands it to onUploadImage', async () => {
const onUploadImage = vi.fn(async () => {});
render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
const input = document.querySelector('input[accept*="image"]') as HTMLInputElement;
const file = new File(['x'], 'thumb.png', { type: 'image/png' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file));
});
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 { hoursLines, periodsFromWeek } from './placeHours'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlacePhotoGallery from './PlacePhotoGallery'
import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
import PlaceRating from '../shared/StarRating'
import TrackColorPicker from '../shared/TrackColorPicker'
@@ -186,6 +186,10 @@ interface PlaceInspectorProps {
tripMembers?: TripMember[]
onSetParticipants?: (assignmentId: number, dayId: number, participantIds: number[]) => 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. */
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
leftWidth?: number
@@ -211,7 +215,7 @@ export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
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,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
}: PlaceInspectorProps) {
@@ -388,6 +392,9 @@ export default function PlaceInspector({
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
onUpdatePlace={onUpdatePlace}
onUploadImage={mode === 'trip' && onUpdatePlace ? onUploadImage : undefined}
attachedImages={pickableImages(placeFiles)}
onImageFromFile={mode === 'trip' && onUpdatePlace ? onImageFromFile : undefined}
onClose={onClose} />
{/* 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
startNameEdit: () => 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
}
/**
* 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
* avatar opens the place's photos; its picture is changed in the editor.
* name (a double-click renames it), the address, and the facts as pills.
*/
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) {
const { t, locale, language } = useTranslation()
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
const { t, locale } = useTranslation()
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
const hasCoords = !!(place.lat && place.lng)
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 w-[57px] flex-none flex-col items-center">
<div className={`rounded-full p-[2.5px] ${ring}`}>
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full">
<PlaceAvatar place={place} category={category} size={52} />
</PlacePhotoGallery>
{onUploadImage
? <PlaceAvatarUpload place={place} category={category} size={52}
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>
<PhotoCredit imageUrl={place.image_url} />
</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
// 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 { 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 { assignmentsApi } from '../../api/client';
import { useTripStore } from '../../store/tripStore';
import { normalizeImageFile } from '../../utils/convertHeic';
import {
participantsWith,
participantsWithout,
placeActions,
splitParticipants,
usePlaceFileUpload,
usePlaceImagePick,
} from './usePlaceActions';
vi.mock('../../utils/convertHeic', () => ({
@@ -206,3 +208,61 @@ describe('usePlaceFileUpload', () => {
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 { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types';
import { normalizeImageFile } from '../../utils/convertHeic';
type Translate = (key: string, params?: Record<string, string | number>) => string;
@@ -132,3 +133,29 @@ export function usePlaceFileUpload(
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
// dialog and the phone sheet, its waiting picture, and the inline new category of both
// ('dialog' and 'sheet').
// FE-PLANNER-PLFORM-001 to FE-PLANNER-PLFORM-014: the place form state behind the desktop
// dialog and the phone sheet, and the inline new category of both ('dialog' and 'sheet').
import { act, renderHook } from '@testing-library/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 { DEFAULT_FORM } from './PlaceFormModal.helpers';
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;
}
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', () => {
function setupCategory(options: Pick<NewCategoryOptions, 'variant' | 'create'>) {
const onCreated = vi.fn();
+4 -45
View File
@@ -1,8 +1,8 @@
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import type { useTranslation } from '../../i18n';
import type { Category, TripFile } from '../../types';
import type { Category } from '../../types';
import { offlineOr } from '../../utils/needsConnection';
import type { useToast } from '../shared/Toast';
import type { PlaceDetailsSelection } from './PlaceDetailsColumn';
@@ -15,7 +15,6 @@ import {
type PlacePrefill,
type ResultField,
} from './PlaceFormModal.helpers';
import type { PendingPlaceImage } from './placeImageChoice';
type Translate = ReturnType<typeof useTranslation>['t'];
type Toast = ReturnType<typeof useToast>;
@@ -41,20 +40,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
// user's and survives. See mergeResult.
const autoFilledRef = useRef<Set<ResultField>>(new Set());
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 [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
// 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);
setDetailsSelection(openingDetailsSelection(place, prefill));
setPendingFiles([]);
setPendingImage(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. */
const pickImage = (url: string | null) => {
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 }));
};
/** A hero image picked in the details column, or none. */
const pickImage = (url: string | null) => setForm((prev) => ({ ...prev, image_url: url ?? undefined }));
/** The details column's description, taken over into the form. */
const adoptDescription = (text: string) => setForm((prev) => ({ ...prev, description: text }));
@@ -164,10 +127,6 @@ export function usePlaceForm({ t, toast, canUploadFiles }: PlaceFormOptions) {
openForm,
handleChange,
pickImage,
pendingImage,
stageUpload,
stageAttached,
removeImage,
adoptDescription,
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 type { RoadLine } from '../Map/RouteCalculator'
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip'
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
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. */
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. */
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.
@@ -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,
* 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
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. So
* the check is the router itself, the way `pinWay` asks it; the spacing between the
* requests is the router's own (`RailLegRouter.route`). The server asks the same way for
* the ways of a hike's leg.
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. An
* offer weighed away from motorways by the second engine is held by one point for a few
* kilometres, and the motorway takes the rest. Either way the choice looked saved and the
* 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({
offer,
@@ -38,9 +65,23 @@ export async function pinAlternative({
signal: AbortSignal
maxPins?: number
}): Promise<PinProof> {
const proof = await pinWay({ offer, current, route: (pins) => route(pins, signal), signal, maxPins })
if (!proof) throw new DOMException('The check was abandoned', 'AbortError')
return proof
const placed: { index: number; lat: number; lng: number }[] = []
if (!offer.direct) {
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 { sameRoad } from '@trek/shared'
import { calculateAlternatives, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
import { calculateAlternatives, sameRoad, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
import type { RailLegRoute, RailLegRouter } from './useRoadtripRoutes'
/** 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`,
// 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.
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',
}}
>
@@ -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>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { folioInk } from './folioColour'
import { isNumbered } from './bookSheets'
@@ -94,7 +94,7 @@ export function PageNumbers({
textAlign: at.align,
fontFamily: fontStack(cfg.font),
fontSize: `${size}pt`,
fontWeight: nearestWeight(cfg.font, 500),
fontWeight: 500,
letterSpacing: '0.08em',
lineHeight: 1,
color: ink.color,
-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