diff --git a/src/api.js b/src/api.js index a0e1603..526246f 100644 --- a/src/api.js +++ b/src/api.js @@ -110,3 +110,34 @@ export async function fetchElevation(lat, lon) { return null } } + +const REVERSE_URL = "/api/reverse" + +/** + * Reverse geocode a point. Returns a place object or null. + * @param {number} lat + * @param {number} lon + * @returns {Promise<{lat, lon, name, address, type, source, raw}|null>} + */ +export async function fetchReverse(lat, lon) { + try { + const params = new URLSearchParams({ lat: String(lat), lon: String(lon) }) + const resp = await fetch(`${REVERSE_URL}?${params}`, { timeout: 5000 }) + if (!resp.ok) return null + const data = await resp.json() + if (!data.results || data.results.length === 0) return null + const r = data.results[0] + return { + lat: r.lat, + lon: r.lon, + name: r.name, + address: null, + type: r.type, + source: r.source, + matchCode: null, + raw: r.raw || {}, + } + } catch { + return null + } +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 1f2d8eb..77d4b63 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -5,6 +5,7 @@ import { Protocol } from 'pmtiles' import { layers, namedTheme } from 'protomaps-themes-base' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' +import { fetchReverse } from '../api' const ROUTE_SOURCE = 'route-source' const ROUTE_LAYER_PREFIX = 'route-layer-' @@ -41,6 +42,8 @@ const MapView = forwardRef(function MapView(_, ref) { const previewMarkerRef = useRef(null) const watchIdRef = useRef(null) const currentThemeRef = useRef('dark') + // Flag to suppress map-click when a stop pin was clicked + const pinClickedRef = useRef(false) const stops = useStore((s) => s.stops) const route = useStore((s) => s.route) @@ -110,9 +113,43 @@ const MapView = forwardRef(function MapView(_, ref) { ) } - map.on('click', () => { + // Map click — drop pin and reverse geocode + map.on('click', (e) => { + // If a stop pin was just clicked, skip the pin-drop + if (pinClickedRef.current) { + pinClickedRef.current = false + return + } + if (window.innerWidth < 768) setSheetState('collapsed') - useStore.getState().clearSelectedPlace() + + const { lng, lat } = e.lngLat + + // Immediately set a "Dropped pin" placeholder so PlaceDetail opens with coords + useStore.getState().setSelectedPlace({ + lat, + lon: lng, + name: 'Dropped pin', + address: null, + type: null, + source: 'map_click', + matchCode: null, + raw: {}, + }) + + // Reverse geocode in background — update place when result arrives + fetchReverse(lat, lng).then((place) => { + if (!place) return + // Only update if the selected place is still this pin (user hasn't clicked elsewhere) + const current = useStore.getState().selectedPlace + if (current && Math.abs(current.lat - lat) < 0.00001 && Math.abs(current.lon - lng) < 0.00001) { + useStore.getState().setSelectedPlace({ + ...place, + lat, + lon: lng, + }) + } + }) }) map.on('load', () => { @@ -204,8 +241,10 @@ const MapView = forwardRef(function MapView(_, ref) { if (!selectedPlace) return - // Fly to selected place - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) + // Only fly to place if it came from search (not map-click which already centered) + if (selectedPlace.source !== 'map_click') { + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) + } // Create preview marker const el = document.createElement('div') @@ -342,6 +381,8 @@ const MapView = forwardRef(function MapView(_, ref) { el.addEventListener('click', (e) => { e.stopPropagation() + // Flag so the map-level click handler doesn't fire + pinClickedRef.current = true if (popupRef.current) popupRef.current.remove() const popup = new maplibregl.Popup({ offset: 20, closeButton: true }) .setLngLat([stop.lon, stop.lat]) diff --git a/src/components/PlaceDetail.jsx b/src/components/PlaceDetail.jsx index f338bad..19f6f46 100644 --- a/src/components/PlaceDetail.jsx +++ b/src/components/PlaceDetail.jsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { X, Navigation, Plus, Bookmark, Share2 } from 'lucide-react' +import { useEffect, useState, useRef, useCallback } from 'react' +import { X, Navigation, Plus, Bookmark, ChevronDown, Copy } from 'lucide-react' import toast from 'react-hot-toast' import { useStore } from '../store' import { fetchElevation } from '../api' @@ -15,6 +15,77 @@ function buildAddress(place) { return parts.join(', ') || null } +/** Copy popover — small dropdown beneath the Copy button */ +function CopyPopover({ address, selectedPlace, onClose }) { + const ref = useRef(null) + + // Close on click-outside + useEffect(() => { + function handleClick(e) { + if (ref.current && !ref.current.contains(e.target)) onClose() + } + function handleKey(e) { + if (e.key === 'Escape') onClose() + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [onClose]) + + const copyAddress = () => { + const text = [selectedPlace.name, address].filter(Boolean).join('\n') + navigator.clipboard.writeText(text).then( + () => toast('Address copied'), + () => toast.error('Failed to copy') + ) + onClose() + } + + const copyCoords = () => { + const text = `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}` + navigator.clipboard.writeText(text).then( + () => toast('Coordinates copied'), + () => toast.error('Failed to copy') + ) + onClose() + } + + return ( +
+ + +
+ ) +} + export default function PlaceDetail() { const selectedPlace = useStore((s) => s.selectedPlace) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) @@ -25,6 +96,9 @@ export default function PlaceDetail() { const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null }) const [isMobile, setIsMobile] = useState(false) + const [copyForPlace, setCopyForPlace] = useState(null) + + const closeCopy = useCallback(() => setCopyForPlace(null), []) useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) @@ -33,6 +107,17 @@ export default function PlaceDetail() { return () => window.removeEventListener('resize', check) }, []) + + // Escape key closes panel + useEffect(() => { + if (!selectedPlace) return + function handleKey(e) { + if (e.key === 'Escape') clearSelectedPlace() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [selectedPlace, clearSelectedPlace]) + // Fetch elevation when place changes const placeLat = selectedPlace?.lat const placeLon = selectedPlace?.lon @@ -49,6 +134,7 @@ export default function PlaceDetail() { const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon) const elevation = !elevLoading ? elevResult.value : null + const placeKey = selectedPlace ? `${selectedPlace.lat},${selectedPlace.lon}` : null if (!selectedPlace) return null const address = buildAddress(selectedPlace) @@ -82,18 +168,6 @@ export default function PlaceDetail() { toast('Saved places coming soon') } - const handleShare = () => { - const text = [ - selectedPlace.name, - address, - `${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`, - ].filter(Boolean).join('\n') - navigator.clipboard.writeText(text).then( - () => toast('Copied to clipboard'), - () => toast.error('Failed to copy') - ) - } - const panelContent = ( <> {/* Close button */} @@ -191,14 +265,25 @@ export default function PlaceDetail() { - + {/* Copy dropdown */} +
+ + {copyForPlace === placeKey && ( + + )} +
)