Fix a batch of reported bugs (#1145)

* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)

* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)

* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)

* fix(unraid): point the template at a PNG icon Unraid can render (#1073)

* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)

* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)

* fix(pdf): render persisted place-photo proxy URLs as images (#1130)

* fix(planner): show the selected place category in the edit form (#1134)

* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
This commit is contained in:
Maurice
2026-06-11 13:31:43 +02:00
committed by GitHub
parent 3c040fab11
commit e65acb3de7
17 changed files with 385 additions and 105 deletions
+14 -3
View File
@@ -131,10 +131,21 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom
// Pan to the selected place without changing zoom. Offset the centre by the
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
const selected = places.find(p => p.id === selectedPlaceId)
if (selected?.lat && selected?.lng) {
map.panTo([selected.lat, selected.lng], { animate: true })
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
map.panTo(map.unproject(point), { animate: true })
} else {
map.panTo(latlng, { animate: true })
}
}
}
prev.current = selectedPlaceId
+4
View File
@@ -553,6 +553,10 @@ export function MapViewGL({
zoom: Math.max(map.getZoom(), 14),
pitch: mapbox3d ? 45 : 0,
duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather
// than the geometric centre (where the bottom panel would cover it).
padding: paddingOpts,
})
} catch { /* noop */ }
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
+17
View File
@@ -259,6 +259,23 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('colosseum.jpg')
})
it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an <img>, not the category icon (#1130)', async () => {
const args = {
...richArgs,
assignments: {
'10': [{
...assignmentForDay,
place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' },
}],
} as any,
}
await downloadTripPDF(args)
const iframe = getIframe()
// The proxy path (no file extension) must still embed as an absolute <img>.
expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes')
expect(iframe!.srcdoc).toContain('class="place-thumb"')
})
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
let photoCalled = false
server.use(
+7 -2
View File
@@ -55,6 +55,10 @@ function absUrl(url) {
function safeImg(url) {
if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url
// The in-app place-photo proxy always streams a JPEG but has no file extension
// (it ends in …/bytes), so the extension check below would wrongly reject it —
// which is why persisted place photos showed as category icons in the PDF.
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
}
@@ -254,9 +258,10 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1'
// Image: direct > google photo > fallback icon
// Image: direct > google photo > fallback icon. Both go through safeImg
// so the proxy path is resolved to an absolute URL the PDF can load.
const directImg = safeImg(place.image_url)
const googleImg = photoMap[place.id] || null
const googleImg = safeImg(photoMap[place.id])
const img = directImg || googleImg
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
@@ -270,6 +270,18 @@ describe('PlaceFormModal', () => {
expect(screen.getByText(/No category/i)).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
// Regression: form.category_id is a string but the option values were numbers,
// so CustomSelect's strict-equality match failed and the trigger fell back to
// "No category". With string option values the chosen category renders.
const cat = buildCategory({ name: 'Museums' });
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
render(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />);
// Dropdown is closed, so the only place the category name can appear is the trigger.
expect(screen.getByText('Museums')).toBeInTheDocument();
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
@@ -636,7 +636,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
options={[
{ value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({
value: c.id,
// form.category_id is a string; CustomSelect matches options by
// strict equality, so the option value must be a string too —
// otherwise the chosen category never renders in the trigger.
value: String(c.id),
label: c.name,
})),
]}