mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f3e2f0bcf | |||
| f956ab07f3 | |||
| 736594696e | |||
| 1be997f7d8 | |||
| 5a8ff16afa | |||
| 4435dc32b8 | |||
| f5a33e0c1e | |||
| a8326b6f5a | |||
| 1e999d968a | |||
| d0f74c9f02 | |||
| f14f1e41b9 | |||
| 9138e722bb | |||
| 9695e269e3 | |||
| 82bdf62c7b | |||
| 8893e3afe0 | |||
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 | |||
| 890473ebd5 | |||
| 2b86d1fda7 | |||
| 1487ad97f1 | |||
| fc27b254bb | |||
| 658536c217 | |||
| 00018959c6 | |||
| 117af4a8a7 | |||
| 84e18e4954 | |||
| 090e754077 | |||
| 4322159d8e | |||
| bdf5767a5f | |||
| e8b071aecd | |||
| f128346a54 | |||
| 5bf6af7afc | |||
| 1a941a6f1d | |||
| 508e17bc11 | |||
| 4d00858b8a | |||
| 7e5b23808d | |||
| bfcc205f05 | |||
| a6a9f3bbdd | |||
| 5651c11803 | |||
| ff749611c6 | |||
| 49f7a447d3 | |||
| ea5447056b | |||
| baec23cf73 | |||
| a202f2dc95 | |||
| a2951f2c99 | |||
| 6395ef655f | |||
| cbf9325e7e | |||
| a32465ad32 | |||
| 01abb90797 | |||
| bce74d6c56 | |||
| 75d0593198 | |||
| 82b8eb1067 | |||
| ce9c94a77b | |||
| 7a7876b9f4 | |||
| 9b8626622f | |||
| dad2a3d523 | |||
| b3180b898e | |||
| 81047917a1 | |||
| 31dba65906 | |||
| 770fb2a2b3 | |||
| 8c7fc989dc | |||
| 58204e72bd | |||
| 53cefba3f3 | |||
| 46613ada23 | |||
| 5d334d7ec8 | |||
| cd60d4d859 | |||
| 454fc1a5f4 | |||
| 767d91146a | |||
| c6b3f609b7 | |||
| 7b50b12fe6 | |||
| d8f7143b4c | |||
| d206435af1 | |||
| d8213bfa42 | |||
| 8c0707f34c | |||
| 8c6f5deab2 | |||
| 23c446b7e9 | |||
| 8ec560884e | |||
| 269fce0331 | |||
| 72661196ee | |||
| fabefed39f | |||
| 507d4f73bc | |||
| 6e43490ba0 | |||
| 79fb71bc92 | |||
| beb2c0c258 | |||
| bebfeeff97 | |||
| 35566690e9 | |||
| 357bcb72e3 | |||
| 47e04b506f | |||
| 9373667084 | |||
| 87dc70a4c0 | |||
| 5b6de2bb16 |
@@ -53,3 +53,54 @@ 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 |
|
||||
|
||||
@@ -112,6 +112,9 @@ 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 }}
|
||||
|
||||
@@ -108,6 +108,7 @@ 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.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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: [] };
|
||||
}
|
||||
@@ -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('button', { name, exact: true })
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { 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-back'),
|
||||
target: p => p.locator('.st-head-close'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1431,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
|
||||
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
|
||||
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
|
||||
+28
-1
@@ -47,14 +47,41 @@
|
||||
},
|
||||
"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",
|
||||
@@ -81,7 +108,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.6.5",
|
||||
"zod": "^4.6.7",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 854,
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 226,
|
||||
"complexity": 222,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 131,
|
||||
"max-params": 33,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 557,
|
||||
"react-hooks/set-state-in-effect": 195,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 126,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 132,
|
||||
"max-lines-per-function": 130,
|
||||
"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": 46,
|
||||
"react-refresh/only-export-components": 45,
|
||||
"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": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1932,
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -69,6 +69,15 @@
|
||||
"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"],
|
||||
@@ -592,7 +601,6 @@
|
||||
"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"]
|
||||
|
||||
@@ -1123,8 +1123,6 @@
|
||||
"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",
|
||||
|
||||
@@ -117,11 +117,6 @@ 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}',
|
||||
@@ -144,6 +139,20 @@ 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',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"packing.ts": 6,
|
||||
"pdf.ts": 1,
|
||||
"perm.ts": 4,
|
||||
"places.ts": 10,
|
||||
"places.ts": 9,
|
||||
"planner.ts": 53,
|
||||
"reservations.ts": 21,
|
||||
"roadtrip.ts": 36,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
"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",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"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,
|
||||
@@ -52,13 +51,12 @@
|
||||
"components/Studio/BookSheetsView.tsx": 3,
|
||||
"components/Studio/PageNumbers.tsx": 1,
|
||||
"components/Studio/PeerCursors.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 8,
|
||||
"components/Studio/StudioCanvas.tsx": 22,
|
||||
"components/Studio/PhotoView.tsx": 4,
|
||||
"components/Studio/SpreadView.tsx": 4,
|
||||
"components/Studio/StudioCanvas.tsx": 12,
|
||||
"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,
|
||||
@@ -73,7 +71,6 @@
|
||||
"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,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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": 1195,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"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/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1193,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"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": 1131,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"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": 2087
|
||||
"src/styles/studio.css": 1918
|
||||
}
|
||||
|
||||
@@ -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": 17,
|
||||
"src/components/Planner/PlaceInspector.tsx": 16,
|
||||
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebar.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
|
||||
@@ -82,8 +82,6 @@
|
||||
"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,
|
||||
@@ -98,7 +96,6 @@
|
||||
"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,
|
||||
|
||||
@@ -100,13 +100,13 @@
|
||||
"components/Roadtrip/serviceMarker.ts": 1,
|
||||
"components/Settings/useAppearanceEditor.ts": 5,
|
||||
"components/Studio/BookSheetsView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 3,
|
||||
"components/Studio/PhotoView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"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,7 +132,6 @@
|
||||
"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,
|
||||
@@ -252,7 +251,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": 27,
|
||||
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
|
||||
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"components/Settings/AppearanceSettingsTab.tsx": 1,
|
||||
"components/Settings/PasskeysSection.tsx": 1,
|
||||
"components/Studio/BookSheetsView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 2,
|
||||
"components/Studio/PhotoView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
|
||||
+4
-3
@@ -67,6 +67,7 @@ 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
|
||||
@@ -410,10 +411,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. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
|
||||
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && (
|
||||
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||
<Suspense fallback={null}><AssistantHost /></Suspense>
|
||||
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
|
||||
@@ -165,4 +165,46 @@ 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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantErrorCode,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantRenderUploadResponse,
|
||||
type AssistantScope,
|
||||
type AssistantShareRequest,
|
||||
@@ -102,14 +103,16 @@ 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. Threads, turns,
|
||||
* decisions, undo and stop exist under both; 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 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.
|
||||
*/
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -143,6 +146,13 @@ 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,6 +3,7 @@ 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';
|
||||
@@ -45,7 +46,11 @@ 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);
|
||||
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
// 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 placeholder = useTypewriterPlaceholder(
|
||||
suggestions.map((s) => s.prompt),
|
||||
fallback,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
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 { useNavigate } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -12,7 +24,9 @@ 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';
|
||||
@@ -29,6 +43,16 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +63,14 @@ interface AssistantHeaderProps {
|
||||
* 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 }: AssistantHeaderProps) {
|
||||
export default function AssistantHeader({
|
||||
tripId,
|
||||
thread,
|
||||
tripTitle,
|
||||
onClose,
|
||||
dragProps,
|
||||
tripSwitch,
|
||||
}: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
@@ -48,6 +79,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
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);
|
||||
@@ -64,6 +96,14 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
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,
|
||||
@@ -122,6 +162,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
|
||||
{tripChat && (
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
@@ -155,10 +196,12 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
</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" />
|
||||
@@ -186,6 +229,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
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">
|
||||
@@ -202,7 +246,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
)}
|
||||
</PopoverListPanel>
|
||||
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
<ContextMenu menu={menu.menu} onClose={closeMenu} />
|
||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
@@ -218,3 +262,49 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
</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,7 +17,8 @@ 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; what a
|
||||
* 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
|
||||
* 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,6 +101,14 @@ 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 trip picker. Nothing shows until the
|
||||
* window; outside a trip it opens on the home chat. 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. Only a
|
||||
// trip's chat counts: 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. A
|
||||
// trip's chat and the home chat count: 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 === 'trip')
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind !== 'journey')
|
||||
: false
|
||||
);
|
||||
const keys = useAssistantShortcutKeys();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -25,6 +25,24 @@ 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. */
|
||||
@@ -185,8 +203,9 @@ export default function AssistantPlaceCard({
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
||||
<div className="mt-auto flex min-w-0 gap-1.5 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,6 +21,7 @@ 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';
|
||||
@@ -105,6 +106,7 @@ afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
globalThis.IntersectionObserver = RealObserver;
|
||||
setForcedOffline(false);
|
||||
setOpenTour(null);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -238,3 +240,22 @@ 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,6 +29,7 @@ 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.
|
||||
@@ -41,8 +42,9 @@ export default function AssistantSurface({
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronRight, Luggage } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
@@ -6,12 +7,19 @@ import { Spinner } from '../shared/Spinner';
|
||||
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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 (
|
||||
@@ -31,9 +39,10 @@ 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) => (
|
||||
{trips.map((trip, i) => (
|
||||
<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,20 +28,15 @@ export default function AssistantWelcome({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan.
|
||||
const book = useAssistantScope().kind === 'journey';
|
||||
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
|
||||
const scope = useAssistantScope().kind;
|
||||
const book = scope === 'journey';
|
||||
const 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={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
|
||||
/>
|
||||
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||
{!canEdit && (
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
|
||||
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
|
||||
@@ -53,6 +48,12 @@ 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,8 +1,9 @@
|
||||
/**
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-012
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-016
|
||||
*
|
||||
* The desktop window around the chat: when it shows at all, the button it
|
||||
* opens from, the trip picker outside a trip, closing that keeps the chat, and
|
||||
* opens from, the home chat outside a trip with its way into one trip's chat
|
||||
* and back, 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
|
||||
@@ -36,12 +37,14 @@ 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>
|
||||
@@ -66,6 +69,11 @@ 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>
|
||||
),
|
||||
@@ -136,12 +144,36 @@ describe('AssistantWindow', () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
|
||||
it('FE-AI-WIN-004: outside a trip it opens on the home chat, whose head opens one trip chat and goes back', async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
|
||||
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' }));
|
||||
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', () => {
|
||||
@@ -206,6 +238,34 @@ 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();
|
||||
@@ -238,6 +298,10 @@ 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,20 +1,21 @@
|
||||
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';
|
||||
|
||||
@@ -30,17 +31,18 @@ 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.
|
||||
*/
|
||||
|
||||
interface WindowTrip {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
type WindowTrip = AssistantChatTrip;
|
||||
|
||||
interface AssistantWindowProps {
|
||||
/** The trip on screen; null outside a trip, where the window asks which one first. */
|
||||
/** The trip on screen; null outside a trip, where the window opens on the home chat. */
|
||||
trip: WindowTrip | null;
|
||||
/** Opens what a card or an applied change points at, from the trip on screen. */
|
||||
onOpen?: (target: EntityTarget) => void;
|
||||
@@ -70,12 +72,16 @@ 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);
|
||||
@@ -150,6 +156,7 @@ 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)}
|
||||
@@ -162,6 +169,7 @@ function ChatWindow({
|
||||
onOpen={onOpen}
|
||||
onFocusDay={onFocusDay}
|
||||
onRevealMap={onRevealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -177,19 +185,21 @@ 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 asks again.
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (!open) setPicked(null);
|
||||
}
|
||||
|
||||
const current = trip ?? picked;
|
||||
const current = trip ?? picked ?? home.chat;
|
||||
const atHome = current.id === HOME_CHAT_KEY;
|
||||
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
|
||||
const openPicked = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
@@ -198,6 +208,14 @@ 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) => {
|
||||
@@ -209,52 +227,27 @@ 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={onScreen ? onOpen && openOnScreen : openPicked}
|
||||
onOpen={openTarget}
|
||||
// Only the planner on screen has days to select and a map to bring back.
|
||||
onFocusDay={onScreen ? onFocusDay : undefined}
|
||||
onRevealMap={onScreen ? onRevealMap : undefined}
|
||||
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
||||
tripSwitch={tripSwitch}
|
||||
/>
|
||||
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
|
||||
{/* 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,6 +107,7 @@ 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',
|
||||
@@ -224,6 +225,17 @@ 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
|
||||
|
||||
@@ -127,6 +127,10 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
|
||||
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'propose_hike',
|
||||
'create_hike',
|
||||
'propose_tour',
|
||||
'create_tour',
|
||||
'create_paddle_tour',
|
||||
'create_ski_tour',
|
||||
'update_tour',
|
||||
'propose_city_walk',
|
||||
'create_city_walk',
|
||||
@@ -134,6 +138,9 @@ 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. */
|
||||
@@ -455,4 +462,5 @@ 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,6 +34,17 @@ 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-016
|
||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
|
||||
*
|
||||
* 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 { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -67,6 +67,9 @@ 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');
|
||||
@@ -92,6 +95,15 @@ 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,6 +10,7 @@ import {
|
||||
type BookSpread,
|
||||
findBookGrid,
|
||||
findBookTemplate,
|
||||
findDecoratedTemplate,
|
||||
setBookCover,
|
||||
} from '@trek/shared';
|
||||
|
||||
@@ -57,6 +58,15 @@ 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. */
|
||||
@@ -82,7 +92,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);
|
||||
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
|
||||
return template ? t(template.labelKey) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ 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, Ruler, Sparkles } from 'lucide-react';
|
||||
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react';
|
||||
|
||||
import type { PackLook } from '../cardTypes';
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,6 +24,19 @@ 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,6 +28,7 @@ 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';
|
||||
@@ -170,6 +171,7 @@ 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. */
|
||||
@@ -234,6 +236,7 @@ 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,6 +8,7 @@ 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';
|
||||
@@ -33,6 +34,7 @@ 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. */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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'>;
|
||||
@@ -0,0 +1,12 @@
|
||||
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,7 +20,16 @@ import { ROW_GLYPH, type RowChange } from './checklistModel';
|
||||
* list, and the rows of a run of changes with their include choice.
|
||||
*/
|
||||
|
||||
/** A group's head: its colour, its name, a badge, and who looks after it (the ones a change drops faded). */
|
||||
/** 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.
|
||||
*/
|
||||
export function ChecklistHead({
|
||||
color,
|
||||
name,
|
||||
@@ -38,14 +47,17 @@ export function ChecklistHead({
|
||||
lead?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 pt-1">
|
||||
<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) }}
|
||||
>
|
||||
{lead ?? <CategoryDot color={color} />}
|
||||
<bdi title={name} className="min-w-0 truncate text-caption font-semibold text-content">
|
||||
<bdi title={name} className="min-w-0 flex-1 truncate text-caption font-semibold text-content">
|
||||
{name}
|
||||
</bdi>
|
||||
{badge}
|
||||
{(people.length > 0 || removed.length > 0) && (
|
||||
<span className="ms-auto flex flex-none items-center gap-1.5">
|
||||
{(badge || people.length > 0 || removed.length > 0) && (
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
{badge}
|
||||
<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, quiet beside its name. */
|
||||
/** How many rows a group holds, a white pill at the end of its band. */
|
||||
function GroupCount({ count }: { count: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t('tours.packing.items', { count })}
|
||||
className="flex-none text-caption tabular-nums text-content-faint"
|
||||
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"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
@@ -231,7 +231,7 @@ function BagHead({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!bag) {
|
||||
const badge = inChange ? undefined : <GroupCount count={group.count} />;
|
||||
const badge = <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={inChange ? undefined : <GroupCount count={group.count} />}
|
||||
badge={<GroupCount count={group.count} />}
|
||||
people={group.assignees}
|
||||
removed={group.assigneesRemoved}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../../../i18n';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import { formatRunPace, runPaceUnitText } from '../../../Tours/run/runPace';
|
||||
|
||||
/**
|
||||
* How a card writes its numbers, the way the rest of TREK does for this
|
||||
@@ -13,7 +14,7 @@ import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
* units. A card hands these to its words as `{value}`, never a raw number.
|
||||
*/
|
||||
export function useCardFormat() {
|
||||
const { locale } = useTranslation();
|
||||
const { locale, t } = useTranslation();
|
||||
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
return useMemo(() => {
|
||||
@@ -37,6 +38,8 @@ export function useCardFormat() {
|
||||
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
|
||||
},
|
||||
count: (value: number) => number.format(value),
|
||||
/** "5:30 min/km" from seconds per kilometre, per mile with distances in miles. */
|
||||
pace: (secondsPerKm: number) => `${formatRunPace(secondsPerKm, unit)} ${runPaceUnitText(unit, t)}`,
|
||||
};
|
||||
}, [locale, twelveHour, unit]);
|
||||
}, [locale, twelveHour, unit, t]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
|
||||
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042, FE-AI-TOUR-061 to FE-AI-TOUR-062
|
||||
*
|
||||
* A route in the chat as the Tours tab shows a hike: its figures and grade,
|
||||
* a profile only where the heights are known, the profile, the stop chips
|
||||
@@ -387,4 +387,44 @@ describe('TourRouteCard', () => {
|
||||
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-061: a bike tour names its profile and its riding time, a run its pace, and waits for one it lacks', () => {
|
||||
const bike: Route = {
|
||||
...HIKE,
|
||||
activity: 'bike',
|
||||
routingProfile: 'gravel',
|
||||
minutesBasis: 'router',
|
||||
limit: null,
|
||||
grades: null,
|
||||
};
|
||||
const { container } = renderCard(bike);
|
||||
expect(screen.getByText('Gravel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-grade')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
|
||||
expect(container.querySelector('[data-field="pace"]')).toBeNull();
|
||||
|
||||
const run: Route = {
|
||||
...bike,
|
||||
activity: 'run',
|
||||
routingProfile: 'trail',
|
||||
minutesBasis: 'pace',
|
||||
paceSecondsPerKm: 330,
|
||||
};
|
||||
const paced = renderCard(run);
|
||||
expect(screen.getByText('Trail running')).toBeInTheDocument();
|
||||
expect(paced.container.querySelector('[data-field="pace"]')).toHaveTextContent('5:30 min/km');
|
||||
|
||||
renderCard({ ...run, paceSecondsPerKm: null, minutes: null });
|
||||
expect(screen.getByText('Tell the assistant your pace to save this run.')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Plan this' });
|
||||
expect(buttons[buttons.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-062: a whole paddle plan offers "Plan this"; one with a leg that failed does not', () => {
|
||||
const plan: Route = { ...HIKE, activity: 'kayak', source: 'plan', limit: null, grades: null, schedule: null };
|
||||
renderCard(plan);
|
||||
expect(screen.getByRole('button', { name: 'Plan this' })).toBeInTheDocument();
|
||||
renderCard({ ...plan, name: 'Broken', warnings: [{ kind: 'legs_failed', at: null, count: 1 }] });
|
||||
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import RouteSketch from '../parts/RouteSketch';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
|
||||
import { sketchOf } from './tourRouteSketchModel';
|
||||
import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { PROFILE_WORDS, routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* The head of a route card: the route drawn small beside its name, the
|
||||
@@ -63,14 +63,16 @@ export function TourRouteFigures({ card }: { card: TourRouteCard }) {
|
||||
/**
|
||||
* The grade of a hike, as the hike's own card shows it: the hardest SAC step
|
||||
* along the way, uncertain when too little of it is graded, with what it
|
||||
* means, the routing limit and where grades come from in its tooltip. Any
|
||||
* other tour says what kind it is instead.
|
||||
* means, the routing limit and where grades come from in its tooltip. A bike
|
||||
* tour or a run names its profile instead, any other tour what kind it is.
|
||||
*/
|
||||
export function TourGrade({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.activity !== 'hike' || !card.grades) {
|
||||
const icon = createElement(tourDayRowIcon(card.activity), { size: 10, strokeWidth: 2.2, 'aria-hidden': true });
|
||||
return <SoftPill icon={icon}>{t(TOUR_TYPE_WORDS[card.activity].labelKey)}</SoftPill>;
|
||||
// A bike tour or a run says what it was planned for: "Gravel", "Trail running".
|
||||
const words = card.routingProfile ? PROFILE_WORDS[card.routingProfile] : TOUR_TYPE_WORDS[card.activity];
|
||||
return <SoftPill icon={icon}>{t(words.labelKey)}</SoftPill>;
|
||||
}
|
||||
const { highest, unknownShare } = card.grades;
|
||||
const uncertain = unknownShare >= 0.5;
|
||||
@@ -138,7 +140,7 @@ export function TourRouteSketch({
|
||||
export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const fields = factKeysOf(card).flatMap((key) => {
|
||||
const fields: Array<{ key: string; label: string; value: string }> = factKeysOf(card).flatMap((key) => {
|
||||
const value = factValue(card, key);
|
||||
if (value === null) return [];
|
||||
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
|
||||
@@ -148,6 +150,10 @@ export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||
return [{ key, label, value: text }];
|
||||
});
|
||||
// A run's time is its distance at the runner's pace: the pace stands beside it.
|
||||
if (card.paceSecondsPerKm != null) {
|
||||
fields.push({ key: 'pace', label: t('tours.run.pace'), value: format.pace(card.paceSecondsPerKm) });
|
||||
}
|
||||
return <CardFields fields={fields} />;
|
||||
}
|
||||
|
||||
@@ -173,9 +179,9 @@ export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; o
|
||||
title={name}
|
||||
meta={card.label ?? undefined}
|
||||
>
|
||||
{((card.activity === 'hike' && card.grades) || card.schedule) && (
|
||||
{((card.activity === 'hike' && card.grades) || card.routingProfile || card.schedule) && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
|
||||
{((card.activity === 'hike' && card.grades) || card.routingProfile) && <TourGrade card={card} />}
|
||||
<DayPill card={card} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-TOUR-033 to FE-AI-TOUR-034
|
||||
* FE-AI-TOUR-033 to FE-AI-TOUR-034, FE-AI-TOUR-063
|
||||
*
|
||||
* A held tour change in its row: the route drawn small with its figures and
|
||||
* grade and a small profile, the kind of tour as the row's tile; a tour whose
|
||||
@@ -71,4 +71,10 @@ describe('TourRouteInRow', () => {
|
||||
expect(delta).toHaveTextContent('+1.6 km');
|
||||
expect(delta).toHaveTextContent('+20 min');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-063: a held bike tour or run names its profile in its row, never a hike grade', () => {
|
||||
renderRow({ ...PENDING, activity: 'bike', routingProfile: 'road', grades: null, limit: null });
|
||||
expect(screen.getByText('Road')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-grade')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
|
||||
<span className="min-w-0 flex-1">
|
||||
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
|
||||
</span>
|
||||
{grades && grades.highest !== null && <TourGrade card={card} />}
|
||||
{((grades && grades.highest !== null) || card.routingProfile) && <TourGrade card={card} />}
|
||||
</span>
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
|
||||
@@ -18,6 +18,7 @@ import CardHeader from '../parts/CardHeader';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourRouteBody } from './TourRouteCard';
|
||||
import { TourGrade } from './TourRouteHead';
|
||||
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
|
||||
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||
@@ -114,6 +115,8 @@ function WayOption({
|
||||
{grades && grades.highest !== null && (
|
||||
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
|
||||
)}
|
||||
{/* Ways of a ride or a run may differ by profile: each says its own. */}
|
||||
{entry.card.routingProfile && <TourGrade card={entry.card} />}
|
||||
</span>
|
||||
<WayFigures way={way} unit={unit} />
|
||||
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
ChartNoAxesColumn,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleSlash,
|
||||
Copy,
|
||||
Footprints,
|
||||
History,
|
||||
Luggage,
|
||||
MapPinMinus,
|
||||
MapPinPlus,
|
||||
Palette,
|
||||
Repeat,
|
||||
Route,
|
||||
Snowflake,
|
||||
TriangleAlert,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
@@ -95,10 +113,21 @@ 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: {
|
||||
propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
|
||||
propose_tour: { stepKey: 'assistant.step.planningRoute' },
|
||||
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
|
||||
plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
|
||||
get_tour_plan: { stepKey: 'assistant.step.readingTour' },
|
||||
@@ -106,6 +135,14 @@ 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: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sacAt,
|
||||
sacPaint,
|
||||
steepStretches,
|
||||
takeWaitsKey,
|
||||
wayGroups,
|
||||
} from './tourRouteModel';
|
||||
import { sketchOf } from './tourRouteSketchModel';
|
||||
@@ -136,13 +137,33 @@ describe('tourRouteModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
|
||||
it('FE-AI-TOUR-009: "Plan this" takes a planned way of every type, a city walk, a whole paddle or ski plan', () => {
|
||||
expect(canTake(HIKE)).toBe(true);
|
||||
expect(canTake({ ...HIKE, routed: false })).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
|
||||
expect(canTake({ ...HIKE, activity: 'bike', routingProfile: 'gravel' })).toBe(true);
|
||||
expect(canTake({ ...HIKE, activity: 'run', routingProfile: 'trail', routed: false })).toBe(false);
|
||||
expect(canTake(SAVED)).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(true);
|
||||
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan' })).toBe(true);
|
||||
// A plan with a leg that could not be planned is no tour yet; a hike is never a plan.
|
||||
const failed = [{ kind: 'legs_failed' as const, at: null, count: 1 }];
|
||||
expect(canTake({ ...HIKE, activity: 'ski', source: 'plan', warnings: failed })).toBe(false);
|
||||
expect(canTake({ ...HIKE, source: 'plan' })).toBe(false);
|
||||
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
|
||||
// Another way for a leg of a saved hike changes that hike; it is not created anew.
|
||||
expect(canTake({ ...HIKE, placeId: 12 })).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-AI-TOUR-060: a run waits for the runner's pace; a bike tour, a hike and a run with a pace wait for nothing", () => {
|
||||
expect(takeWaitsKey({ ...HIKE, activity: 'run', routingProfile: 'running' })).toBe('assistant.card.tour.needsPace');
|
||||
expect(takeWaitsKey({ ...HIKE, activity: 'run', paceSecondsPerKm: 330 })).toBeNull();
|
||||
expect(takeWaitsKey({ ...HIKE, activity: 'bike' })).toBeNull();
|
||||
expect(takeWaitsKey(HIKE)).toBeNull();
|
||||
// A ride's router time is its riding time; a run's time is at its pace.
|
||||
expect(timeLabelKey({ activity: 'bike', minutesBasis: 'router' })).toBe('tours.bike.timeEstimated');
|
||||
expect(timeLabelKey({ activity: 'run', minutesBasis: 'pace' })).toBe('tours.run.timeEstimated');
|
||||
expect(timeLabelKey({ activity: 'hike', minutesBasis: 'router' })).toBe('tours.scope.routed');
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
|
||||
|
||||
@@ -226,14 +226,31 @@ export function routePoints(card: TourRouteCard): [number, number][] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "Plan this" can take the proposal: a hike whose way was planned,
|
||||
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
|
||||
* 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.
|
||||
*/
|
||||
export function canTake(card: TourRouteCard): boolean {
|
||||
if (card.source !== 'proposal') return false;
|
||||
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
|
||||
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;
|
||||
return card.activity === 'walk' || (LAND_PLANS.has(card.activity) && card.routed);
|
||||
}
|
||||
|
||||
/** The proposals on land "Plan this" takes once their way is planned. */
|
||||
const LAND_PLANS: ReadonlySet<TourRouteCard['activity']> = new Set(['hike', 'bike', 'run']);
|
||||
|
||||
/** Why "Plan this" waits on a card it can take: a run is saved with the runner's pace only. Null when nothing is missing. */
|
||||
export function takeWaitsKey(card: TourRouteCard): string | null {
|
||||
return card.activity === 'run' && card.paceSecondsPerKm == null ? NEEDS_PACE.labelKey : null;
|
||||
}
|
||||
|
||||
const NEEDS_PACE = { labelKey: 'assistant.card.tour.needsPace' } as const;
|
||||
|
||||
/** A share as words take it, "40 %". */
|
||||
export function percentText(share: number): string {
|
||||
return `${Math.round(share * 100)} %`;
|
||||
|
||||
@@ -92,10 +92,25 @@ const TIME_WORDS = {
|
||||
planned: { labelKey: 'tours.scope.planned' },
|
||||
stations: { labelKey: 'tours.scope.plannedWithStays' },
|
||||
piste: { labelKey: 'tours.ski.time' },
|
||||
pace: { labelKey: 'tours.run.timeEstimated' },
|
||||
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
|
||||
|
||||
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
|
||||
/** What each bike and run profile is called, as the Tours tab names it. */
|
||||
export const PROFILE_WORDS = {
|
||||
touring: { labelKey: 'tours.bike.profile.touring' },
|
||||
road: { labelKey: 'tours.bike.profile.road' },
|
||||
gravel: { labelKey: 'tours.bike.profile.gravel' },
|
||||
mtb: { labelKey: 'tours.bike.profile.mtb' },
|
||||
running: { labelKey: 'tours.run.profile.running' },
|
||||
trail: { labelKey: 'tours.run.profile.trail' },
|
||||
} as const satisfies Record<NonNullable<TourRouteCard['routingProfile']>, { labelKey: string }>;
|
||||
|
||||
/**
|
||||
* The name of a route's time: by how it was reckoned (a bike tour's router
|
||||
* time is its riding time), else a paddle's own, else a walking time.
|
||||
*/
|
||||
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
|
||||
if (card.minutesBasis === 'router' && card.activity === 'bike') return 'tours.bike.timeEstimated';
|
||||
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
|
||||
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { hasUndecided } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { useChangeHover } from '../../useChangeHover';
|
||||
import { tourTarget } from './tourLook';
|
||||
import { canTake, routePoints } from './tourRouteModel';
|
||||
import { canTake, routePoints, takeWaitsKey } from './tourRouteModel';
|
||||
import { takeInThread, takeOfProposal } from './tourTakeModel';
|
||||
|
||||
/**
|
||||
@@ -104,7 +104,8 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plan this" on a proposed hike or city walk: one tap adds the tour. The
|
||||
* "Plan this" on a proposed tour (a hike, a bike tour, a run, a city walk)
|
||||
* or a paddle or ski plan: one tap adds the tour. The
|
||||
* server holds the tour it creates as a change, without a model turn, and
|
||||
* the tap decides it at once, since tapping "Plan this" already is the
|
||||
* decision; the assistant then answers to what was added. Once the tour is
|
||||
@@ -148,9 +149,10 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
|
||||
}, []);
|
||||
|
||||
const visible = canTake(card) && !!trip && can('place_edit', trip);
|
||||
let reasonKey: string | null = null;
|
||||
let reasonKey: string | null;
|
||||
if (offline) reasonKey = 'assistant.error.offline';
|
||||
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
|
||||
else reasonKey = takeWaitsKey(card);
|
||||
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
|
||||
|
||||
const take = useCallback(async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
import { ASSISTANT_TOUR_FILE_EXTENSIONS, 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 (the server checks again), and how big one file may be. */
|
||||
/** What the booking import reads and the route files taken in as tours (the server checks again), and how big one file may be. */
|
||||
const ATTACH_EXTENSIONS: readonly string[] = [
|
||||
'.pdf',
|
||||
'.eml',
|
||||
@@ -16,6 +16,7 @@ const ATTACH_EXTENSIONS: readonly string[] = [
|
||||
'.htm',
|
||||
'.txt',
|
||||
...LLM_PHOTO_EXTENSIONS,
|
||||
...ASSISTANT_TOUR_FILE_EXTENSIONS,
|
||||
];
|
||||
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@@ -29,7 +30,7 @@ function hasFiles(e: DragEvent): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
|
||||
* Hands a booking file or a route file (GPX, KML, KMZ) 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.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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,6 +3,7 @@ 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';
|
||||
@@ -41,6 +42,10 @@ 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();
|
||||
@@ -60,6 +65,8 @@ 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;
|
||||
@@ -85,6 +92,14 @@ 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;
|
||||
|
||||
@@ -96,5 +111,5 @@ export function useAssistantPlaceStrip() {
|
||||
[hoverPoint]
|
||||
);
|
||||
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover };
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour };
|
||||
}
|
||||
|
||||
@@ -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 { suggestionsFor, tripPhase } from './assistantModel';
|
||||
import { HOME_SUGGESTIONS, suggestionsFor, tripPhase } from './assistantModel';
|
||||
import { useAssistantFocus } from './useAssistantFocus';
|
||||
|
||||
export interface ReadySuggestion {
|
||||
@@ -21,7 +21,9 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,9 +37,11 @@ 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 canEdit = tripChat ? !!trip && can('place_edit', trip) : (journeyRole ?? 'owner') !== 'viewer';
|
||||
const home = scope.kind === 'home';
|
||||
const canEdit = tripChat ? !!trip && can('place_edit', trip) : home || (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({
|
||||
@@ -53,7 +57,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) }));
|
||||
}, [tripChat, trip, hasPlaces, canEdit, kind, label, t]);
|
||||
}, [home, tripChat, trip, hasPlaces, canEdit, kind, label, t]);
|
||||
|
||||
return { suggestions, canEdit };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share.
|
||||
// FE-COMP-TZHOOK-001 to -009: the world clocks both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -97,4 +97,27 @@ describe('useDashboardTimezones', () => {
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2694: Chrome on Windows names no zone for a system zone it cannot map.
|
||||
it('FE-COMP-TZHOOK-008: a browser that names no zone still gets the default clocks', () => {
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
|
||||
this: Intl.DateTimeFormat
|
||||
) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string };
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/London', 'Asia/Tokyo']);
|
||||
expect(() => result.current.zones.map((tz) => result.current.timeIn(tz))).not.toThrow();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-TZHOOK-009: a stored zone this browser does not know is left out instead of crashing the clocks', () => {
|
||||
seedSettings(['Europe/Berlin', 'Mars/Olympus_Mons', '']);
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/Berlin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { browserTimeZone, isValidTimeZone } from '../../utils/localDate';
|
||||
|
||||
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'];
|
||||
|
||||
@@ -38,13 +39,15 @@ export function shortZone(tz: string): string {
|
||||
* clocks tick every 30 seconds, `adding` toggles the zone picker.
|
||||
*/
|
||||
export function useDashboardTimezones(locale: string) {
|
||||
const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const home = browserTimeZone();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const isLoaded = useSettingsStore((s) => s.isLoaded);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const stored = useSettingsStore((s) => s.settings.dashboard_timezones);
|
||||
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
|
||||
const zones = stored ?? [home, ...DEFAULT_ZONES];
|
||||
// A browser that names no zone (#2694) or a zone this browser does not know
|
||||
// would throw while rendering a clock, so such entries are left out.
|
||||
const zones = (stored ?? [home, ...DEFAULT_ZONES]).filter(isValidTimeZone);
|
||||
const setZones = (next: string[]) => {
|
||||
updateSetting('dashboard_timezones', next).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-021
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
|
||||
|
||||
// Plyr needs a real media pipeline, so the player is stubbed here.
|
||||
vi.mock('./VideoPlayer', () => ({
|
||||
@@ -17,6 +17,7 @@ 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 = [
|
||||
@@ -247,4 +248,82 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,14 @@ import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import VideoPlayer from './VideoPlayerLazy'
|
||||
import { usePhotoZoom } from './usePhotoZoom'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isRtlLanguage, 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
|
||||
@@ -19,6 +21,8 @@ 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. */
|
||||
@@ -28,10 +32,21 @@ const ROUND_BTN: React.CSSProperties = {
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}
|
||||
|
||||
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
|
||||
export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }: Props) {
|
||||
const [idx, setIdx] = useState(startIndex)
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||
const { t } = useTranslation()
|
||||
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 photo = photos[idx]
|
||||
// Zoom starts over on every photo (#1484).
|
||||
@@ -46,12 +61,12 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
if (e.key === 'ArrowLeft') (rtl ? next : prev)()
|
||||
if (e.key === 'ArrowRight') (rtl ? prev : next)()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [prev, next, onClose])
|
||||
}, [prev, next, onClose, rtl])
|
||||
|
||||
// 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.
|
||||
@@ -79,7 +94,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
}
|
||||
// horizontal swipe
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
if (dx < 0) next()
|
||||
if (dx < 0 !== rtl) next()
|
||||
else prev()
|
||||
}
|
||||
touchStart.current = null
|
||||
@@ -89,7 +104,13 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
|
||||
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',
|
||||
@@ -144,15 +165,15 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
|
||||
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
|
||||
{hasPrev && (
|
||||
<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,
|
||||
<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,
|
||||
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} />
|
||||
<ChevronLeft size={22} className="rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -179,27 +200,33 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
|
||||
{/* Next button */}
|
||||
{hasNext && (
|
||||
<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,
|
||||
<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,
|
||||
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} />
|
||||
<ChevronRight size={22} className="rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Caption — bottom center overlay */}
|
||||
{photo.caption && (
|
||||
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
|
||||
<div style={{ position: 'absolute', bottom: 20, insetInline: 0, zIndex: 5, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||
<p style={{
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontStyle: 'italic',
|
||||
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
|
||||
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5, maxWidth: '70%', textAlign: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
||||
padding: '6px 14px', borderRadius: 10,
|
||||
}}>{photo.caption}</p>
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
calculateAlternatives,
|
||||
avoidedClasses,
|
||||
routeEngineFor,
|
||||
sameRoad,
|
||||
furthestFrom,
|
||||
osrmLegSegment,
|
||||
} from './RouteCalculator'
|
||||
|
||||
@@ -1204,37 +1202,6 @@ 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 }] })),
|
||||
|
||||
@@ -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 type { RouteUsageSurface } from '@trek/shared'
|
||||
import { furthestFrom, sameRoad, 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,41 +803,6 @@ 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.
|
||||
@@ -1103,87 +1068,3 @@ 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]
|
||||
}
|
||||
|
||||
@@ -397,6 +397,19 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('€')
|
||||
})
|
||||
|
||||
// #2692: a place's description and notes print as the planner shows them, not as raw markdown.
|
||||
it('FE-COMP-TRIPPDF-016c: renders markdown in a place description, sanitized', async () => {
|
||||
const description = ['**Colosseum** tickets', '', '| Gate | Time |', '| --- | --- |', '| A | 9:00 |', '', '<script>alert(1)</script>'].join('\n')
|
||||
const place = { ...placeWithDetails, description }
|
||||
const md = { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] } as typeof richArgs.assignments }
|
||||
await downloadTripPDF(md)
|
||||
const html = getIframe()?.srcdoc ?? ''
|
||||
expect(html).toContain('<strong>Colosseum</strong>')
|
||||
expect(html).toContain('<td>9:00</td>')
|
||||
expect(html).not.toContain('**Colosseum**')
|
||||
expect(html).not.toContain('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => {
|
||||
const usdArgs = {
|
||||
...richArgs,
|
||||
@@ -796,7 +809,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER')
|
||||
expect(html).toContain('ICE 599 · reservations.meta.platform 7 · reservations.meta.seat 21A · FRA → BER')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
|
||||
@@ -811,7 +824,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC')
|
||||
expect(html).toContain('IC 1 · reservations.meta.platform 3 · BER → MUC')
|
||||
expect(html).toContain('EC 2 · MUC → ZRH')
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
import { onlyMyPlan } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
@@ -231,6 +232,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
let hasTransportNotes = false
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const markdown = createPdfMarkdown()
|
||||
// The store read is the fallback, not the source: a caller that forgets the
|
||||
// prop still prints the reader's own format instead of silently reverting.
|
||||
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
|
||||
@@ -439,8 +441,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index]
|
||||
subtitleLines = [[
|
||||
l.airline, l.flight_number, l.train_number,
|
||||
l.platform ? `Gl. ${l.platform}` : '',
|
||||
l.seat ? `Seat ${l.seat}` : '',
|
||||
l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
l.seat ? `${tr('reservations.meta.seat')} ${l.seat}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
source?.confirmation_number,
|
||||
].filter(Boolean).join(' · ')].filter(Boolean)
|
||||
@@ -471,7 +473,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
// Multi-leg: one line per leg so every train number + segment route shows,
|
||||
// with the segment's own booking code where it has one (#1943).
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
[l.train_number, l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
l.confirmation_number]
|
||||
.filter(Boolean).join(' · '))
|
||||
@@ -479,7 +481,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
subtitle = [meta.train_number, meta.platform ? `${tr('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${tr('reservations.meta.seat')} ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
@@ -587,10 +589,10 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
</div>
|
||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${place.description ? markdown.row(place.description) : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
|
||||
${place.notes ? markdown.row(place.notes) : ''}
|
||||
${item.data.notes ? markdown.row(item.data.notes) : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
@@ -836,7 +838,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
.info-spacer { width: 13px; flex-shrink: 0; }
|
||||
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
|
||||
.info-text.muted { color: #94a3b8; }
|
||||
.info-text.italic { font-style: italic; }
|
||||
${PDF_MARKDOWN_CSS}
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
|
||||
@@ -930,6 +932,7 @@ ${tripMapHtml}
|
||||
${daysHtml}
|
||||
${pluginSectionsHtml}
|
||||
</body></html>`
|
||||
markdown.dispose()
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createMarkdownHtml } from '../shared/markdownHtml';
|
||||
|
||||
/**
|
||||
* A place's description and notes are markdown in the app, so the export
|
||||
* renders them the same way instead of printing the raw syntax (#2692): one
|
||||
* info row per text, the HTML from the app's own sanitized pipeline.
|
||||
*/
|
||||
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
|
||||
const markdown = createMarkdownHtml();
|
||||
return {
|
||||
row: (text) =>
|
||||
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
|
||||
dispose: markdown.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/** The markdown's own spacing, lists, tables and links at the export's small size. */
|
||||
export const PDF_MARKDOWN_CSS = `
|
||||
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
|
||||
.md > :first-child { margin-top: 0; }
|
||||
.md > :last-child { margin-bottom: 0; }
|
||||
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
|
||||
.md ul, .md ol { padding-inline-start: 14px; }
|
||||
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
|
||||
.md table { border-collapse: collapse; }
|
||||
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
|
||||
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
|
||||
.md a { color: inherit; text-decoration: underline; }
|
||||
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
|
||||
`;
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PACKPOP-001 to FE-PACKPOP-008
|
||||
// FE-PACKPOP-001 to FE-PACKPOP-009
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { useRef } from 'react'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -56,6 +56,15 @@ 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,6 +7,8 @@ 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
|
||||
@@ -14,11 +16,12 @@ interface PopoverItemProps {
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
export function PopoverItem({ icon, label, onClick, active = false, danger = false, muted = false, trailing }: PopoverItemProps) {
|
||||
export function PopoverItem({ icon, label, onClick, active = false, current = 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',
|
||||
|
||||
@@ -43,6 +43,7 @@ import './dayPlanRows.css'
|
||||
|
||||
import { TypeTile } from './bookings/bookingParts'
|
||||
import { typeInfo } from './bookings/bookingsModel'
|
||||
import { transportSubtitle } from './transportDetailModel'
|
||||
import { dayHeadStays } from './dayHeadStays'
|
||||
import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
@@ -2166,24 +2167,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `${t('reservations.meta.platform')} ${res.__leg.platform}` : '', res.__leg.seat ? `${t('reservations.meta.seat')} ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'train') {
|
||||
subtitle = [meta.train_number, meta.platform ? `${t('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${t('reservations.meta.seat')} ${meta.seat}` : ''].filter(Boolean).join(', ')
|
||||
}
|
||||
// A leg of a multi-leg flight or train shows its own detail (#2701).
|
||||
const subtitle = transportSubtitle(res, t) ?? ''
|
||||
|
||||
// A transit journey (#1065) renders its itinerary inline —
|
||||
// line badges in their colors instead of a plain subtitle,
|
||||
|
||||
@@ -110,8 +110,8 @@ describe('PlaceDetailsColumn', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PDC-004: renders author and licence under every Commons picture', async () => {
|
||||
renderColumn()
|
||||
it('FE-PDC-004: renders author and licence under the Commons picture in play', async () => {
|
||||
renderColumn({ selectedImageUrl: COMMONS_PHOTO.url })
|
||||
|
||||
const credit = await screen.findByText('Alice')
|
||||
// Links to the file description page, where the full terms are.
|
||||
@@ -122,9 +122,11 @@ 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()
|
||||
renderColumn({ selectedImageUrl: GOOGLE_PHOTO.url })
|
||||
|
||||
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 () => {
|
||||
@@ -611,17 +613,24 @@ 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()
|
||||
renderColumn({ selectedImageUrl: '/api/maps/place-photo/p0/bytes' })
|
||||
|
||||
expect(await screen.findByText('Author 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Author 0').closest('a')).toHaveAttribute(
|
||||
@@ -672,9 +681,12 @@ 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"]' })
|
||||
expect(licence.closest('p')).toHaveTextContent('Alice')
|
||||
expect(licence.closest('p')?.textContent).not.toContain('·')
|
||||
const line = licence.parentElement?.parentElement as HTMLElement
|
||||
expect(line).toHaveTextContent('Alice')
|
||||
expect(line.textContent).not.toContain('·')
|
||||
|
||||
fireEvent.click(tile)
|
||||
expect(onPickImage).toHaveBeenCalledWith(COMMONS_PHOTO.url)
|
||||
@@ -788,3 +800,41 @@ 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,7 +4,6 @@ import {
|
||||
ArrowRight,
|
||||
Bike,
|
||||
Building2,
|
||||
Check,
|
||||
ChefHat,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -33,22 +32,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'
|
||||
|
||||
/** 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>
|
||||
}
|
||||
export { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './placeEnrichment'
|
||||
|
||||
interface PlaceDetailsColumnProps {
|
||||
selection: PlaceDetailsSelection | null
|
||||
/** Currently chosen hero image, so the picked tile can show as picked. */
|
||||
selectedImageUrl?: string
|
||||
selectedImageUrl?: string | null
|
||||
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
|
||||
@@ -75,47 +74,6 @@ 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'
|
||||
|
||||
/**
|
||||
@@ -161,6 +119,7 @@ export default function PlaceDetailsColumn({
|
||||
locale = 'en-US',
|
||||
fluid = false,
|
||||
variant = 'sheet',
|
||||
image,
|
||||
t,
|
||||
}: PlaceDetailsColumnProps): React.ReactElement {
|
||||
const dialog = variant === 'dialog'
|
||||
@@ -183,9 +142,8 @@ export default function PlaceDetailsColumn({
|
||||
return
|
||||
}
|
||||
|
||||
const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
|
||||
const cached = readCachedEnrichment(selection, language)
|
||||
if (cached) {
|
||||
enrichmentCache.set(selectionKey, cached)
|
||||
setData(cached)
|
||||
setState('ready')
|
||||
return
|
||||
@@ -217,8 +175,7 @@ export default function PlaceDetailsColumn({
|
||||
)
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return
|
||||
enrichmentCache.set(selectionKey, result)
|
||||
writeSession(selectionKey, result)
|
||||
storeEnrichment(selection, language, result)
|
||||
setData(result)
|
||||
setState('ready')
|
||||
})
|
||||
@@ -279,6 +236,16 @@ 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. */}
|
||||
@@ -331,7 +298,6 @@ 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}
|
||||
@@ -389,119 +355,31 @@ export default function PlaceDetailsColumn({
|
||||
)
|
||||
}
|
||||
|
||||
/** 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({
|
||||
/** The picker under its label; nothing while there is no picture to offer. */
|
||||
function PictureBlock({
|
||||
photos,
|
||||
selectedImageUrl,
|
||||
chosen,
|
||||
onPickImage,
|
||||
dialog = false,
|
||||
own,
|
||||
dialog,
|
||||
t,
|
||||
}: {
|
||||
photos: PlacePhotoCandidate[]
|
||||
selectedImageUrl?: string
|
||||
chosen: ChosenImage
|
||||
onPickImage: (url: string | null) => void
|
||||
dialog?: boolean
|
||||
own?: PlaceImageOwn
|
||||
dialog: boolean
|
||||
t: TranslationFn
|
||||
}): React.ReactElement | null {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
|
||||
if (!offersPictures(photos, own)) return null
|
||||
const picker = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} />
|
||||
return (
|
||||
<Block label={t('places.details.pickImage')} dialog={dialog}>
|
||||
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{body}</div> : body}
|
||||
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{picker}</div> : picker}
|
||||
</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.
|
||||
*
|
||||
@@ -533,75 +411,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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-010: the place form's openings and save
|
||||
// FE-PLANNER-PLFORMH-001 to FE-PLANNER-PLFORMH-011: 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 } from '../../types';
|
||||
import type { Assignment, Place, TripFile } from '../../types';
|
||||
import {
|
||||
DEFAULT_FORM,
|
||||
duplicateName,
|
||||
@@ -155,4 +155,18 @@ 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,4 +1,5 @@
|
||||
import type { Assignment, Place, RoadtripStopType } from '@trek/shared'
|
||||
import { pendingImagePayload, type PendingImagePayload, type PendingPlaceImage } from './placeImageChoice'
|
||||
import type { PlaceDetailsSelection } from './PlaceDetailsColumn'
|
||||
|
||||
export interface PlaceFormData {
|
||||
@@ -27,7 +28,8 @@ 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.
|
||||
image_url?: string
|
||||
// Null drops the place's picture on save; undefined keeps it.
|
||||
image_url?: string | null
|
||||
// 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
|
||||
@@ -308,6 +310,8 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,6 +326,7 @@ 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
|
||||
@@ -332,6 +337,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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"]') as HTMLInputElement;
|
||||
const fileInput = document.querySelector('input[type="file"][multiple]') 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"]') as HTMLInputElement;
|
||||
const fileInput = document.querySelector('input[type="file"][multiple]') 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"]') as HTMLInputElement;
|
||||
const fileInput = document.querySelector('input[type="file"][multiple]') 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"]') as HTMLInputElement, {
|
||||
fireEvent.change(document.querySelector('input[type="file"][multiple]') 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,9 +8,11 @@ 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, Paperclip, X, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
|
||||
import { Search, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { PlaceContactFields } from './PlaceContactFields'
|
||||
import { weekFromPeriods } from './placeHours'
|
||||
@@ -41,7 +43,6 @@ 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
|
||||
@@ -121,8 +122,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const {
|
||||
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
|
||||
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
|
||||
adoptDescription, warnIfDuplicate,
|
||||
adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage,
|
||||
} = 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)
|
||||
@@ -358,7 +362,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
|
||||
@@ -442,6 +446,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
handleRemoveFile: removeFile,
|
||||
handlePaste,
|
||||
pickImage,
|
||||
image,
|
||||
adoptDescription,
|
||||
hasTimeError,
|
||||
handleSubmit,
|
||||
@@ -606,6 +611,7 @@ 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()}
|
||||
@@ -879,6 +885,7 @@ 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`}>
|
||||
@@ -1117,44 +1124,3 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// 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$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
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,6 +23,7 @@ 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 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -54,6 +55,7 @@ beforeAll(() => {
|
||||
|
||||
import PlaceInspector from './PlaceInspector';
|
||||
import { mapsApi } from '../../api/client';
|
||||
import { __clearEnrichmentCacheForTests } from './placeEnrichment';
|
||||
|
||||
// ── Shared fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,6 +102,8 @@ 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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -970,18 +974,21 @@ describe('PlaceInspector', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Custom thumbnail upload (#1136) ──────────────────────────────────────────
|
||||
// ── The avatar opens the place's photos (#1136) ─────────────────────────────
|
||||
|
||||
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: without onUploadImage the avatar has no upload control', () => {
|
||||
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-050: with no picture and nobody to ask for one the avatar stays a plain picture', () => {
|
||||
seedStore(useAuthStore, { placesEnrichEnabled: false });
|
||||
render(<PlaceInspector {...defaultProps} />);
|
||||
expect(screen.queryByRole('button', { name: 'Show photos' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
|
||||
});
|
||||
|
||||
// ── Track colour (#776) ──────────────────────────────────────────────────────
|
||||
@@ -1415,15 +1422,23 @@ describe('PlaceInspector', () => {
|
||||
expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull();
|
||||
});
|
||||
|
||||
// ── Custom thumbnail callbacks (#1136) ───────────────────────────────────────
|
||||
// ── The avatar's gallery (#1136) ────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
|
||||
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 });
|
||||
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} onUploadImage={onUploadImage} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
|
||||
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
|
||||
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());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => {
|
||||
@@ -1545,13 +1560,28 @@ describe('PlaceInspector', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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-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-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 PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
|
||||
import PlacePhotoGallery from './PlacePhotoGallery'
|
||||
import { BlurredCode } from '../shared/BookingCode'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import TrackColorPicker from '../shared/TrackColorPicker'
|
||||
@@ -186,10 +186,6 @@ 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
|
||||
@@ -215,7 +211,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, onUploadImage, onImageFromFile, onRate,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate,
|
||||
leftWidth = 0, rightWidth = 0, clearMapPill = false,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
|
||||
}: PlaceInspectorProps) {
|
||||
@@ -392,9 +388,6 @@ 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). */}
|
||||
@@ -598,19 +591,17 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
|
||||
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) {
|
||||
const { t, locale, language } = 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
|
||||
@@ -619,13 +610,9 @@ 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}`}>
|
||||
{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} />}
|
||||
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full">
|
||||
<PlaceAvatar place={place} category={category} size={52} />
|
||||
</PlacePhotoGallery>
|
||||
</div>
|
||||
<PhotoCredit imageUrl={place.image_url} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
@@ -23,3 +23,65 @@ export function bookingDayLabel(date: string, locale: string): string {
|
||||
export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string {
|
||||
return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode');
|
||||
}
|
||||
|
||||
/** The per-leg detail the day plan's leg rows carry (utils/dayMerge's `__leg`). */
|
||||
export interface TransportLegDetail {
|
||||
index: number;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
airline?: string | null;
|
||||
flight_number?: string | null;
|
||||
train_number?: string | null;
|
||||
platform?: string | null;
|
||||
seat?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle of a transport row in the day plan, desktop and phone alike. A
|
||||
* leg of a multi-leg train or flight names its own train or flight, platform
|
||||
* and seat (#2701); a whole booking reads them off its metadata. Null for the
|
||||
* other transport types, which each view labels its own way.
|
||||
*/
|
||||
export function transportSubtitle(
|
||||
res: { type: string; metadata?: unknown; __leg?: TransportLegDetail | null },
|
||||
t: Translate
|
||||
): string | null {
|
||||
const meta = parseMeta(res.metadata);
|
||||
const leg = res.__leg;
|
||||
const trainParts = (d: { train_number?: unknown; platform?: unknown; seat?: unknown }) => [
|
||||
str(d.train_number),
|
||||
str(d.platform) ? `${t('reservations.meta.platform')} ${str(d.platform)}` : '',
|
||||
str(d.seat) ? `${t('reservations.meta.seat')} ${str(d.seat)}` : '',
|
||||
];
|
||||
if (leg) {
|
||||
const parts = res.type === 'train' ? trainParts(leg) : [str(leg.airline), str(leg.flight_number)];
|
||||
if (leg.from || leg.to) parts.push([leg.from, leg.to].filter(Boolean).join(' → '));
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [str(meta.airline), str(meta.flight_number)];
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([str(meta.departure_airport), str(meta.arrival_airport)].filter(Boolean).join(' → '));
|
||||
}
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'train') return trainParts(meta).filter(Boolean).join(', ');
|
||||
return null;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
||||
}
|
||||
|
||||
function parseMeta(metadata: unknown): Record<string, unknown> {
|
||||
let meta = metadata;
|
||||
// An earlier bug could double-encode the metadata; reading it twice heals that.
|
||||
for (let i = 0; i < 2 && typeof meta === 'string'; i++) {
|
||||
try {
|
||||
meta = JSON.parse(meta || '{}');
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
}
|
||||
return meta && typeof meta === 'object' ? (meta as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user