mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Map/planner/dashboard polish and small community features (#1155)
* feat(planner): reorder days in a modal instead of a dropdown The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged. * feat(map): explore reliability, Mapbox popups + compass, region-biased search POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out. Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north. /api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result. * feat(dashboard): list-view and mobile polish Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts. Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar. * feat: small community-requested options Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields. * test(shared): bump day-note subtitle limit assertion to 250 * test: align specs with the new search param order and archive label Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
This commit is contained in:
@@ -559,8 +559,10 @@ export const mapsApi = {
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigation } from 'lucide-react'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
|
||||
/**
|
||||
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
|
||||
* pitched, so this shows the current bearing (the arrow points to north) and snaps
|
||||
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
|
||||
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||
* so its height and transparency match the POI pill exactly.
|
||||
*/
|
||||
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
|
||||
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setBearing(map.getBearing())
|
||||
update()
|
||||
map.on('rotate', update)
|
||||
return () => { map.off('rotate', update) }
|
||||
}, [map])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
|
||||
aria-label="Reset north"
|
||||
className="text-content-muted"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', padding: 0,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,12 @@ vi.mock('mapbox-gl', () => ({
|
||||
})),
|
||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
|
||||
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -53,6 +54,7 @@ interface Props {
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
onMapReady?: (map: mapboxgl.Map | null) => void
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
@@ -167,6 +169,7 @@ export function MapViewGL({
|
||||
pois = [],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
@@ -186,10 +189,15 @@ export function MapViewGL({
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
// Single reusable hover popup (name/category/address card) shared by planned
|
||||
// places and POI markers — mirrors the Leaflet map's hover tooltip.
|
||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
onMapReadyRef.current = onMapReady
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
@@ -212,6 +220,16 @@ export function MapViewGL({
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
popupRef.current = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
offset: 18,
|
||||
maxWidth: '240px',
|
||||
className: 'trek-map-popup',
|
||||
})
|
||||
// Hand the map out so the trip planner can render its own compass pill next to
|
||||
// the POI pill (a custom round control instead of Mapbox's default top-right one).
|
||||
onMapReadyRef.current?.(map)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).__trek_map = map
|
||||
|
||||
@@ -357,6 +375,8 @@ export function MapViewGL({
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||
onMapReadyRef.current?.(null)
|
||||
if (reservationOverlayRef.current) {
|
||||
reservationOverlayRef.current.destroy()
|
||||
reservationOverlayRef.current = null
|
||||
@@ -430,6 +450,10 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
// Markers are about to be rebuilt; drop any open hover popup first. A marker
|
||||
// recreated under the pointer (e.g. when its photo streams in) never fires
|
||||
// mouseleave, which would otherwise leave the popup orphaned on the map.
|
||||
popupRef.current?.remove()
|
||||
const ids = new Set(places.map(p => p.id))
|
||||
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
@@ -450,6 +474,12 @@ export function MapViewGL({
|
||||
ev.stopPropagation()
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([place.lng, place.lat])
|
||||
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
|
||||
.addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
// Recreate marker each time rather than patching internal state —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
@@ -471,11 +501,15 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
el.title = poi.name
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { RotateCw, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { POI_CATEGORIES } from './poiCategories'
|
||||
@@ -7,6 +7,8 @@ interface Props {
|
||||
active: Set<string>
|
||||
onToggle: (key: string) => void
|
||||
loadingKeys?: Set<string>
|
||||
/** categories whose last fetch failed → show a retry affordance */
|
||||
errorKeys?: Set<string>
|
||||
/** true when the map moved since the last search → offer "search this area" */
|
||||
moved?: boolean
|
||||
onSearchArea?: () => void
|
||||
@@ -15,8 +17,9 @@ interface Props {
|
||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||
// fill with the category colour (matching their markers); the label shows in a
|
||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
|
||||
|
||||
const frosted: React.CSSProperties = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
aria-label={t(cat.labelKey)}
|
||||
className={on ? '' : 'text-content-muted'}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: on ? cat.color : 'transparent',
|
||||
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
) : (
|
||||
<cat.Icon size={16} strokeWidth={2} />
|
||||
)}
|
||||
{on && !loading && errorKeys?.has(cat.key) && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
|
||||
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{moved && active.size > 0 && (
|
||||
{(moved || anyError) && active.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchArea}
|
||||
@@ -76,10 +86,14 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
||||
color: anyError ? '#ef4444' : undefined,
|
||||
...frosted,
|
||||
}}
|
||||
>
|
||||
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
|
||||
{anyError
|
||||
? <AlertTriangle size={13} strokeWidth={2.4} />
|
||||
: <RotateCw size={13} strokeWidth={2.4} />}
|
||||
{t('poi.searchThisArea')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
|
||||
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
|
||||
// no equivalent, so these produce the same card as an HTML string for a
|
||||
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
|
||||
|
||||
type PlaceWithCategory = Place & {
|
||||
category_color?: string | null
|
||||
category_icon?: string | null
|
||||
category_name?: string | null
|
||||
}
|
||||
|
||||
function esc(s: string | null | undefined): string {
|
||||
if (!s) return ''
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// Render a lucide category icon to an inline SVG string in the given colour.
|
||||
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
|
||||
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
|
||||
// into an <img src> — everything else is a fetch seed, not a displayable URL.
|
||||
function isDisplayablePhoto(url: string | null | undefined): url is string {
|
||||
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
|
||||
}
|
||||
|
||||
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
|
||||
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||
|
||||
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
|
||||
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
|
||||
const img = isDisplayablePhoto(photoUrl)
|
||||
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
|
||||
: ''
|
||||
const category =
|
||||
place.category_name && place.category_icon
|
||||
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
|
||||
: ''
|
||||
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
|
||||
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
|
||||
}
|
||||
|
||||
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
|
||||
export function buildPoiPopupHtml(poi: Poi): string {
|
||||
const cat = POI_CATEGORY_BY_KEY[poi.category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
|
||||
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
|
||||
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
|
||||
return `${CARD_OPEN}${head}${address}</div>`
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import type { Poi } from './poiCategories'
|
||||
|
||||
export interface Bbox { south: number; west: number; north: number; east: number }
|
||||
|
||||
// A request we cancelled on purpose (newer search superseded it) — not a failure.
|
||||
function isAbortError(err: unknown): boolean {
|
||||
const e = err as { name?: string; code?: string } | null
|
||||
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
||||
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
||||
@@ -15,12 +21,18 @@ export function usePoiExplore() {
|
||||
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
||||
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
||||
const [moved, setMoved] = useState(false)
|
||||
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
|
||||
// the pill can offer a retry instead of looking like "no places here".
|
||||
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const bboxRef = useRef<Bbox | null>(null)
|
||||
// activeRef always mirrors the latest active set so async callbacks (fetch
|
||||
// completions) can check whether a category is still wanted.
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
// One in-flight AbortController per category, so re-toggling / re-searching
|
||||
// cancels the previous (possibly slow) Overpass request instead of racing it.
|
||||
const abortRef = useRef<Record<string, AbortController>>({})
|
||||
|
||||
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -28,19 +40,41 @@ export function usePoiExplore() {
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
|
||||
if (on === prev.has(key)) return prev
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(key); else next.delete(key)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
||||
abortRef.current[key]?.abort()
|
||||
const ctrl = new AbortController()
|
||||
abortRef.current[key] = ctrl
|
||||
setLoading(key, true)
|
||||
setError(key, false)
|
||||
try {
|
||||
const res = await mapsApi.pois(key, bbox)
|
||||
const res = await mapsApi.pois(key, bbox, ctrl.signal)
|
||||
// Drop the result if the user toggled this category off while the (slow)
|
||||
// Overpass request was in flight — otherwise stale results re-appear.
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// A superseded request was aborted on purpose — leave its state untouched
|
||||
// so the newer request owns the spinner and results.
|
||||
if (isAbortError(err)) return
|
||||
// A real failure (every Overpass mirror down/timed out): surface it instead
|
||||
// of a silent empty so the user can retry rather than assume "no places".
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
||||
if (activeRef.current.has(key)) setError(key, true)
|
||||
} finally {
|
||||
setLoading(key, false)
|
||||
// Only the latest controller for this key clears the spinner; a superseded
|
||||
// one must not, or it would hide the newer request's in-flight state.
|
||||
if (abortRef.current[key] === ctrl) {
|
||||
setLoading(key, false)
|
||||
delete abortRef.current[key]
|
||||
}
|
||||
}
|
||||
}, [setLoading])
|
||||
}, [setLoading, setError])
|
||||
|
||||
const onViewportChange = useCallback((bbox: Bbox) => {
|
||||
bboxRef.current = bbox
|
||||
@@ -53,6 +87,11 @@ export function usePoiExplore() {
|
||||
const toggle = useCallback((key: string) => {
|
||||
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
||||
setMoved(false)
|
||||
setErrorKeys(new Set())
|
||||
// Switching to another category (or turning off) — cancel any in-flight
|
||||
// fetches so their results can't land after the selection changed.
|
||||
Object.values(abortRef.current).forEach(c => c.abort())
|
||||
abortRef.current = {}
|
||||
if (isOnlyActive) {
|
||||
setActive(new Set())
|
||||
setByCat({})
|
||||
@@ -72,5 +111,5 @@ export function usePoiExplore() {
|
||||
|
||||
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
||||
|
||||
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
|
||||
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
|
||||
}
|
||||
|
||||
@@ -293,6 +293,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
||||
</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>` : ''}
|
||||
${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>` : ''}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
||||
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
||||
@@ -27,6 +28,18 @@ export const NOTE_ICONS = [
|
||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
||||
{ id: 'Bookmark', Icon: Bookmark },
|
||||
{ id: 'Utensils', Icon: Utensils },
|
||||
{ id: 'Wine', Icon: Wine },
|
||||
{ id: 'ParkingSquare', Icon: ParkingSquare },
|
||||
{ id: 'Fuel', Icon: Fuel },
|
||||
{ id: 'Footprints', Icon: Footprints },
|
||||
{ id: 'Mountain', Icon: Mountain },
|
||||
{ id: 'Waves', Icon: Waves },
|
||||
{ id: 'Sun', Icon: Sun },
|
||||
{ id: 'Umbrella', Icon: Umbrella },
|
||||
{ id: 'Music', Icon: Music },
|
||||
{ id: 'Landmark', Icon: Landmark },
|
||||
{ id: 'Gift', Icon: Gift },
|
||||
]
|
||||
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
||||
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
||||
|
||||
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
||||
/>
|
||||
<textarea
|
||||
value={ui.time}
|
||||
maxLength={150}
|
||||
maxLength={250}
|
||||
rows={3}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
||||
className="text-content"
|
||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
||||
/>
|
||||
<div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||
|
||||
@@ -225,16 +225,15 @@ export function DayPlanSidebarToolbar({
|
||||
<ArrowUpDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{reorderOpen && (
|
||||
<DayReorderPopup
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<DayReorderPopup
|
||||
isOpen={reorderOpen}
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
isOpen: boolean
|
||||
days: Day[]
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
@@ -12,12 +14,12 @@ interface DayReorderPopupProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact panel for moving whole days around: drag a row by its grip or use the
|
||||
* up/down arrows, and add a day at the end. Day headers stay untouched — this is
|
||||
* the single surface for ordering. Reorders are applied optimistically by the
|
||||
* store, so the list reflects each move immediately.
|
||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||
* arrows, and add a day at the end. Day headers stay untouched — this is the
|
||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||
* so the list reflects each move immediately.
|
||||
*/
|
||||
export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
|
||||
@@ -41,97 +43,101 @@ export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose
|
||||
}
|
||||
|
||||
const cellBtn = {
|
||||
display: 'grid', placeItems: 'center', width: 26, height: 26,
|
||||
display: 'grid', placeItems: 'center', width: 28, height: 28,
|
||||
border: '1px solid var(--border-faint)', borderRadius: 7,
|
||||
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
||||
} as const
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* outside-click catcher */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
|
||||
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-faint)', borderRadius: 12,
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('dayplan.reorderTitle')}
|
||||
size="md"
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddDay}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
|
||||
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} />
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</p>
|
||||
|
||||
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
|
||||
borderRadius: 8, marginTop: 2,
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
||||
borderRadius: 9,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ArrowUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
const [desc, setDesc] = useState(item.description || '')
|
||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||
const [category, setCategory] = useState(item.category || '')
|
||||
const [addingCategory, setAddingCategoryInline] = useState(false)
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||
const [priority, setPriority] = useState(item.priority || 0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -378,21 +379,52 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(String(v))}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c,
|
||||
label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
{addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
||||
placeholder={t('todo.newCategory')}
|
||||
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
<button type="button" onClick={() => setAddingCategoryInline(false)}
|
||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(String(v))}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c, label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
...(category && !categories.includes(category) ? [{
|
||||
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
|
||||
}] : []),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
|
||||
title={t('todo.newCategory')}
|
||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
|
||||
@@ -35,6 +35,23 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Mapbox GL hover popup — the name/category/address card on marker hover.
|
||||
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
|
||||
onto the popup never steals the marker's mouseleave and causes flicker. */
|
||||
.trek-map-popup { pointer-events: none; }
|
||||
.trek-map-popup .mapboxgl-popup-content {
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
.trek-map-popup .mapboxgl-popup-tip {
|
||||
border-top-color: #fff;
|
||||
border-bottom-color: #fff;
|
||||
border-left-color: #fff;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
.atlas-tooltip {
|
||||
background: rgba(10, 10, 20, 0.6) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('DashboardPage', () => {
|
||||
await user.click(archiveButtons[0]);
|
||||
|
||||
// Switch to the archive filter segment
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
@@ -293,7 +293,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
@@ -442,7 +442,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
@@ -644,7 +644,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Archive filter reveals the archived trip
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
||||
});
|
||||
@@ -687,7 +687,7 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import {
|
||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, SlidersHorizontal, Ticket, X,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
} from 'lucide-react'
|
||||
import '../styles/dashboard.css'
|
||||
|
||||
@@ -120,15 +120,12 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<div className="sec-tools">
|
||||
<div className="seg">
|
||||
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
|
||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||
</div>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
||||
</button>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
<SlidersHorizontal size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTripStore } from '../store/tripStore'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill } from '../components/Map/MapCompassPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
@@ -206,6 +207,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
} = useTripPlanner()
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
@@ -308,11 +310,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
pois={poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
{poiPillEnabled && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
|
||||
{poiPillEnabled && (
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
)}
|
||||
{glMap && <MapCompassPill map={glMap} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import Modal from '../../components/shared/Modal'
|
||||
import CustomSelect from '../../components/shared/CustomSelect'
|
||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
|
||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { useAdmin } from './useAdmin'
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||
handleCreateUser, handleSaveUser,
|
||||
} = admin
|
||||
const [showCreatePw, setShowCreatePw] = React.useState(false)
|
||||
const [showEditPw, setShowEditPw] = React.useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,13 +73,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('common.password')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCreatePw ? 'text' : 'password'}
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('common.password')}
|
||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreatePw(v => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label="Show or hide password"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
@@ -138,13 +151,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showEditPw ? 'text' : 'password'}
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditPw(v => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label="Show or hide password"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
|
||||
@@ -378,8 +378,12 @@
|
||||
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
|
||||
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
|
||||
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; }
|
||||
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
|
||||
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
|
||||
floating alone at the far left. */
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; gap: 6px; }
|
||||
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 15px; font-weight: 600; color: var(--ink); }
|
||||
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 28px; padding: 0 0 0 28px; border: none; border-left: 1px solid var(--line); }
|
||||
.trek-dash .trip-card {
|
||||
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -526,6 +530,9 @@
|
||||
|
||||
/* Hero — immersive cover, title only (the pass is its own card below) */
|
||||
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
|
||||
/* No hover on touch — the lift/zoom just sticks after a tap and looks broken. */
|
||||
.trek-dash .hero-trip:hover { transform: none; box-shadow: var(--sh-lg); }
|
||||
.trek-dash .hero-trip:hover img.bg { transform: none; }
|
||||
.trek-dash .hero-content { padding: 18px; }
|
||||
/* the page already opens with the notification/profile strip, trim its top gap */
|
||||
.trek-dash .page { padding-top: 4px; }
|
||||
@@ -580,25 +587,33 @@
|
||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||
.trek-dash .add-trip-card { min-height: 180px; }
|
||||
|
||||
/* Touch devices have no hover — keep the edit/copy/archive/delete actions
|
||||
visible at all times instead of revealing them on hover. */
|
||||
.trek-dash .trip-actions { opacity: 1; }
|
||||
|
||||
/* Compact list row on mobile — keeps the list view distinct from the grid. The
|
||||
desktop list row uses a 520px cover, which overflowed the phone width: the
|
||||
cover was clipped, the body pushed off-screen, and the fixed 100px cover
|
||||
height left a white strip beneath it. Use a fitting cover that stretches to
|
||||
the row, and show just the title + dates (the counts live in grid view and
|
||||
on the trip itself). */
|
||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
|
||||
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
|
||||
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
|
||||
/* Mobile list row → stacked two-row: row 1 is a slim full-width cover banner
|
||||
(image + title overlay + status top-left), row 2 is just the date, centred.
|
||||
The counts stay grid-view-only on mobile. */
|
||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
|
||||
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
|
||||
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
|
||||
.trek-dash .trips.list-view .trip-name {
|
||||
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
|
||||
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
|
||||
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
|
||||
.trek-dash .trips.list-view .trip-meta { display: none; }
|
||||
|
||||
/* Tools — stacked full-width cards (mockup) */
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0 0 40px; padding: 0; }
|
||||
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
||||
}
|
||||
|
||||
|
||||
@@ -78,10 +78,12 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
|
||||
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
||||
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
|
||||
@@ -95,7 +97,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image }, 'user');
|
||||
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
}
|
||||
|
||||
@@ -55,12 +55,17 @@ export class MapsController {
|
||||
@CurrentUser() user: User,
|
||||
@Body('query') query: unknown,
|
||||
@Query('lang') lang?: string,
|
||||
@Body('locationBias') locationBias?: { lat: number; lng: number; radius?: number },
|
||||
): Promise<MapsSearchResult> {
|
||||
if (!query) {
|
||||
throw new HttpException({ error: 'Search query is required' }, 400);
|
||||
}
|
||||
// Optional bias toward a coordinate (lat/lng[/radius]); improves foreign-region queries.
|
||||
if (locationBias && !(Number.isFinite(locationBias.lat) && Number.isFinite(locationBias.lng))) {
|
||||
throw new HttpException({ error: 'Invalid locationBias: lat and lng must be finite numbers' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.search(user.id, query as string, lang);
|
||||
return await this.maps.search(user.id, query as string, lang, locationBias);
|
||||
} catch (err: unknown) {
|
||||
console.error('Maps search error:', err);
|
||||
throw toHttpException(err, 'Search error', 500);
|
||||
|
||||
@@ -56,8 +56,8 @@ export class MapsService {
|
||||
return this.isSettingDisabled('places_photos_enabled');
|
||||
}
|
||||
|
||||
search(userId: number, query: string, lang?: string): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang) as Promise<MapsSearchResult>;
|
||||
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
|
||||
}
|
||||
|
||||
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise<MapsAutocompleteResult> {
|
||||
|
||||
@@ -250,38 +250,130 @@ interface OverpassPoiElement {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PoiSearchResult {
|
||||
pois: OverpassPoi[];
|
||||
source: 'openstreetmap';
|
||||
truncated: boolean;
|
||||
// True when the requested viewport was too large and got shrunk to a centred
|
||||
// window before querying — the results then cover the middle of the view only.
|
||||
clamped: boolean;
|
||||
}
|
||||
|
||||
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
|
||||
// Reachability and load vary a lot by network/region — the canonical instance is
|
||||
// frequently overloaded (504s) and some community mirrors are unreachable from
|
||||
// certain networks. Racing them means whichever mirror is fastest-reachable for
|
||||
// this user answers, and an overloaded or blocked one never blocks the others.
|
||||
const OVERPASS_MIRRORS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
];
|
||||
// Per-mirror cap. Because mirrors race in parallel this is also the worst-case
|
||||
// total wait before every mirror is given up on and a 502 is returned.
|
||||
const OVERPASS_TIMEOUT_MS = 12000;
|
||||
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes
|
||||
// Overpass scan millions of elements and time out; clamping to a centred window
|
||||
// keeps the query cheap so the explore pill returns fast at ANY zoom level.
|
||||
const MAX_BBOX_SPAN_DEG = 0.5;
|
||||
|
||||
// Short-lived cache so panning back over / re-toggling the same area doesn't
|
||||
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
|
||||
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
|
||||
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
// Cap the number of cached areas so panning across the globe can't grow the map
|
||||
// without bound (entries are evicted oldest-first once the cap is reached).
|
||||
const POI_CACHE_MAX = 500;
|
||||
|
||||
// POST the query to all mirrors at once and return the first one that answers with
|
||||
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
|
||||
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
|
||||
// sum of every dead mirror's timeout.
|
||||
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const controllers: AbortController[] = [];
|
||||
|
||||
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
|
||||
const ctrl = new AbortController();
|
||||
controllers.push(ctrl);
|
||||
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
// Overpass signals an internal timeout / runtime error via `remark` while
|
||||
// still answering HTTP 200 — often fast, with an empty or partial element
|
||||
// set. Treat that as a failed attempt so a healthy mirror wins the race
|
||||
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
|
||||
// still surfaces a real error to the client instead of a silent "no places".
|
||||
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
|
||||
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
|
||||
return data.elements;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Promise.any resolves with the first mirror to return valid JSON, and only
|
||||
// rejects (AggregateError) once every mirror has failed.
|
||||
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
|
||||
} catch {
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
} finally {
|
||||
// Cancel the slower/losing requests — we already have (or have given up on) a result.
|
||||
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
limit = 60,
|
||||
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
|
||||
): Promise<PoiSearchResult> {
|
||||
const filters = CATEGORY_OSM_FILTERS[category];
|
||||
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
|
||||
// Clamp an oversized viewport to a centred window so the query stays cheap and
|
||||
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
|
||||
let { south, west, north, east } = bbox;
|
||||
let clamped = false;
|
||||
if (north - south > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (north + south) / 2;
|
||||
south = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
north = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
if (east - west > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (east + west) / 2;
|
||||
west = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
east = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
|
||||
// Serve repeat pans/toggles of the same area straight from the cache.
|
||||
const cacheKey = `${category}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`;
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
const query = `[out:json][timeout:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
|
||||
let elements: OverpassPoiElement[] = [];
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[] };
|
||||
elements = data.elements || [];
|
||||
} catch (err: any) {
|
||||
if (err?.status) throw err;
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
}
|
||||
const elements = await overpassFetch(query);
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
@@ -309,7 +401,11 @@ export async function searchOverpassPois(
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > limit;
|
||||
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
|
||||
const value: PoiSearchResult = { pois: pois.slice(0, limit), source: 'openstreetmap', truncated, clamped };
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
@@ -450,7 +546,7 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
|
||||
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
|
||||
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const apiKey = getMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -458,6 +554,18 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
const searchBody: Record<string, unknown> = { textQuery: query, languageCode: toApiLang(lang) };
|
||||
// Bias results toward the caller's area when supplied — without it Google Text
|
||||
// Search falls back to the API key's billing region, which skews foreign-region queries.
|
||||
if (locationBias) {
|
||||
searchBody.locationBias = {
|
||||
circle: {
|
||||
center: { latitude: locationBias.lat, longitude: locationBias.lng },
|
||||
radius: locationBias.radius ?? 50000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -465,7 +573,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
||||
},
|
||||
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
|
||||
body: JSON.stringify(searchBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
@@ -485,6 +593,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
rating: p.rating || null,
|
||||
website: p.websiteUri || null,
|
||||
phone: p.nationalPhoneNumber || null,
|
||||
types: p.types || [],
|
||||
source: 'google',
|
||||
}));
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
||||
const res = await makeController({ search }).search(user, 'berlin', 'de');
|
||||
expect(res).toEqual({ places: [], source: 'osm' });
|
||||
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de');
|
||||
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined);
|
||||
});
|
||||
|
||||
it('maps a service error to its status + message', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('dayCreateRequestSchema', () => {
|
||||
});
|
||||
|
||||
describe('dayNoteCreateRequestSchema', () => {
|
||||
it('requires non-empty text capped at 500, time capped at 150', () => {
|
||||
it('requires non-empty text capped at 500, time capped at 250', () => {
|
||||
expect(
|
||||
dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success,
|
||||
).toBe(true);
|
||||
@@ -30,7 +30,7 @@ describe('dayNoteCreateRequestSchema', () => {
|
||||
expect(
|
||||
dayNoteCreateRequestSchema.safeParse({
|
||||
text: 'ok',
|
||||
time: 'y'.repeat(151),
|
||||
time: 'y'.repeat(251),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ export type DayUpdateRequest = z.infer<typeof dayUpdateRequestSchema>;
|
||||
|
||||
export const dayNoteCreateRequestSchema = z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(150).optional(),
|
||||
time: z.string().max(250).optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
|
||||
|
||||
export const dayNoteUpdateRequestSchema = z.object({
|
||||
text: z.string().max(500).optional(),
|
||||
time: z.string().max(150).optional(),
|
||||
time: z.string().max(250).optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.hero.badgeNext': 'ALS NÄCHSTES',
|
||||
'dashboard.hero.badgeRecent': 'KÜRZLICH',
|
||||
'dashboard.hero.tripDates': 'Reisedaten',
|
||||
'dashboard.hero.noDates': 'Keine Daten gesetzt',
|
||||
'dashboard.hero.noDates': 'Freie Planung',
|
||||
'dashboard.hero.travelerOne': '{count} Reisender',
|
||||
'dashboard.hero.travelerMany': '{count} Reisende',
|
||||
'dashboard.hero.destinationOne': '{count} Ziel',
|
||||
|
||||
@@ -125,7 +125,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.hero.badgeNext': 'UP NEXT',
|
||||
'dashboard.hero.badgeRecent': 'RECENT',
|
||||
'dashboard.hero.tripDates': 'Trip dates',
|
||||
'dashboard.hero.noDates': 'No dates set',
|
||||
'dashboard.hero.noDates': 'Open dates',
|
||||
'dashboard.hero.travelerOne': '{count} traveler',
|
||||
'dashboard.hero.travelerMany': '{count} travelers',
|
||||
'dashboard.hero.destinationOne': '{count} destination',
|
||||
|
||||
Reference in New Issue
Block a user