v2.5.4 — Smart map zoom & place files in reservations

- Map auto-fits to day places when selecting a day or place
- Dynamic padding accounts for sidebars and place inspector overlay
- Place-based reservations now show linked files in the bookings tab
- Increased max zoom to 16 for closer detail on nearby places
This commit is contained in:
Maurice
2026-03-23 19:14:14 +01:00
parent aeb530515e
commit 4d3ee08481
5 changed files with 68 additions and 15 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "nomad-client",
"version": "2.5.3",
"version": "2.5.4",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState, useMemo } from 'react'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
@@ -89,19 +89,26 @@ function createPlaceIcon(place, orderNumber, isSelected) {
})
}
function SelectionController({ places, selectedPlaceId }) {
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) {
const map = useMap()
const prev = useRef(null)
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
const place = places.find(p => p.id === selectedPlaceId)
if (place?.lat && place?.lng) {
map.panTo([place.lat, place.lng], { animate: true, duration: 0.5 })
// Fit all day places into view (so you see context), but ensure selected is visible
const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId)
const withCoords = toFit.filter(p => p.lat && p.lng)
if (withCoords.length > 0) {
try {
const bounds = L.latLngBounds(withCoords.map(p => [p.lat, p.lng]))
if (bounds.isValid()) {
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
}
} catch {}
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, map])
}, [selectedPlaceId, places, dayPlaces, paddingOpts, map])
return null
}
@@ -121,7 +128,7 @@ function MapController({ center, zoom }) {
}
// Fit bounds when places change (fitKey triggers re-fit)
function BoundsController({ places, fitKey }) {
function BoundsController({ places, fitKey, paddingOpts }) {
const map = useMap()
const prevFitKey = useRef(-1)
@@ -131,9 +138,9 @@ function BoundsController({ places, fitKey }) {
if (places.length === 0) return
try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true })
if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
} catch {}
}, [fitKey, places, map])
}, [fitKey, places, paddingOpts, map])
return null
}
@@ -153,6 +160,7 @@ const mapPhotoCache = new Map()
export function MapView({
places = [],
dayPlaces = [],
route = null,
selectedPlaceId = null,
onMarkerClick,
@@ -162,7 +170,20 @@ export function MapView({
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
fitKey = 0,
dayOrderMap = {},
leftWidth = 0,
rightWidth = 0,
hasInspector = false,
}) {
// Dynamic padding: account for sidebars + bottom inspector
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (isMobile) return { padding: [40, 20] }
const top = 60
const bottom = hasInspector ? 320 : 60
const left = leftWidth + 40
const right = rightWidth + 40
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
}, [leftWidth, rightWidth, hasInspector])
const [photoUrls, setPhotoUrls] = useState({})
// Fetch Google photos for places that have google_place_id but no image_url
@@ -200,8 +221,8 @@ export function MapView({
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={places} fitKey={fitKey} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MarkerClusterGroup

View File

@@ -245,13 +245,14 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)
}
function PlaceReservationCard({ item, tripId }) {
function PlaceReservationCard({ item, tripId, files = [], onNavigateToFiles }) {
const { updatePlace } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [editing, setEditing] = useState(false)
const confirmed = item.status === 'confirmed'
const placeFiles = files.filter(f => f.place_id === item.placeId)
const handleDelete = async () => {
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
@@ -322,6 +323,26 @@ function PlaceReservationCard({ item, tripId }) {
</div>
{item.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{item.notes}</p>}
{/* Files attached to the place */}
{placeFiles.length > 0 && (
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
<ExternalLink size={11} />
</a>
</div>
))}
{onNavigateToFiles && (
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('reservations.showFiles')}
</button>
)}
</div>
)}
</div>
</div>
</div>
@@ -388,7 +409,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
const total = allPending.length + allConfirmed.length
function renderCard(r) {
if (r._placeRes) return <PlaceReservationCard key={r.id} item={r} tripId={tripId} />
if (r._placeRes) return <PlaceReservationCard key={r.id} item={r} tripId={tripId} files={files} onNavigateToFiles={onNavigateToFiles} />
return <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} />
}

View File

@@ -258,6 +258,13 @@ export default function TripPlannerPage() {
return map
}, [selectedDayId, assignments])
// Places assigned to selected day (with coords) — used for map fitting
const dayPlaces = useMemo(() => {
if (!selectedDayId) return []
const da = assignments[String(selectedDayId)] || []
return da.map(a => a.place).filter(p => p?.lat && p?.lng)
}, [selectedDayId, assignments])
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
const defaultZoom = settings.default_zoom || 10
@@ -323,6 +330,7 @@ export default function TripPlannerPage() {
<div style={{ position: 'absolute', inset: 0 }}>
<MapView
places={mapPlaces()}
dayPlaces={dayPlaces}
route={route}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
@@ -332,6 +340,9 @@ export default function TripPlannerPage() {
tileUrl={mapTileUrl}
fitKey={fitKey}
dayOrderMap={dayOrderMap}
leftWidth={leftCollapsed ? 0 : leftWidth}
rightWidth={rightCollapsed ? 0 : rightWidth}
hasInspector={!!selectedPlace}
/>
{routeInfo && (

View File

@@ -1,6 +1,6 @@
{
"name": "nomad-server",
"version": "2.5.3",
"version": "2.5.4",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",