Allow moving a saved city's pin to update its coordinates
In edit mode, the coordinate text is replaced with a small map showing a draggable marker; dropping it autosaves the new lat/lng. Name and country stay editable via text fields only. New-city creation still uses the search box and shows plain coordinates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
11b6d493e7
commit
0b73929fea
5 changed files with 89 additions and 7 deletions
44
client/src/components/city/LocationPicker.tsx
Normal file
44
client/src/components/city/LocationPicker.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'leaflet/dist/leaflet.css';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { MapContainer, TileLayer, Marker } from 'react-leaflet';
|
||||
import type L from 'leaflet';
|
||||
import type { CityStatus } from '@citytracker/shared';
|
||||
import { cityMarkerIcon } from '../map/CityMarker';
|
||||
|
||||
interface LocationPickerProps {
|
||||
lat: number;
|
||||
lng: number;
|
||||
status: CityStatus;
|
||||
onChange: (lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
export function LocationPicker({ lat, lng, status, onChange }: LocationPickerProps) {
|
||||
const icon = useMemo(() => cityMarkerIcon(status), [status]);
|
||||
const markerRef = useRef<L.Marker>(null);
|
||||
|
||||
return (
|
||||
<div className="location-picker">
|
||||
<MapContainer center={[lat, lng]} zoom={11} className="location-picker-map" scrollWheelZoom={false}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker
|
||||
position={[lat, lng]}
|
||||
icon={icon}
|
||||
draggable
|
||||
ref={markerRef}
|
||||
eventHandlers={{
|
||||
dragend: () => {
|
||||
const marker = markerRef.current;
|
||||
if (!marker) return;
|
||||
const { lat: newLat, lng: newLng } = marker.getLatLng();
|
||||
onChange(newLat, newLng);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</MapContainer>
|
||||
<p className="coords-hint">Drag the pin to update the location.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ export function CityMap({ cities, focusCity }: CityMapProps) {
|
|||
/>
|
||||
<InitialFocus focusCity={focusCity} />
|
||||
{cities.map((city) => (
|
||||
<Marker key={city.id} position={[city.lat, city.lng]} icon={cityMarkerIcon(city)}>
|
||||
<Marker key={city.id} position={[city.lat, city.lng]} icon={cityMarkerIcon(city.status)}>
|
||||
<Popup>
|
||||
<div className="map-popup">
|
||||
<strong>{city.name}</strong>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import L from 'leaflet';
|
||||
import type { City } from '@citytracker/shared';
|
||||
import type { CityStatus } from '@citytracker/shared';
|
||||
|
||||
export function cityMarkerIcon(city: City): L.DivIcon {
|
||||
const color = city.status === 'visited' ? '#2f9e44' : '#1971c2';
|
||||
export function cityMarkerIcon(status: CityStatus): L.DivIcon {
|
||||
const color = status === 'visited' ? '#2f9e44' : '#1971c2';
|
||||
return L.divIcon({
|
||||
className: 'city-marker',
|
||||
html: `<span class="marker-pin" style="background:${color}"></span>`,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useAuth } from '../context/AuthContext';
|
|||
import { useCity, useDeleteCity, useUploadPhoto, useDeletePhoto } from '../hooks/useCities';
|
||||
import * as citiesApi from '../api/cities';
|
||||
import { CitySearchInput } from '../components/city/CitySearchInput';
|
||||
import { LocationPicker } from '../components/city/LocationPicker';
|
||||
import { LinkListEditor } from '../components/city/LinkListEditor';
|
||||
import { ParticipantPicker } from '../components/city/ParticipantPicker';
|
||||
import { PhotoUploader } from '../components/city/PhotoUploader';
|
||||
|
|
@ -189,6 +190,14 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleLocationChange(lat: number, lng: number) {
|
||||
setForm((prev) => {
|
||||
const next: FormState = { ...prev, lat, lng };
|
||||
scheduleSave(next, true);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGeocodeSelect(result: GeocodeResult) {
|
||||
setForm((prev) => {
|
||||
const next: FormState = {
|
||||
|
|
@ -249,10 +258,22 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
|
|||
</label>
|
||||
</div>
|
||||
{form.lat !== null && form.lng !== null && (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<LocationPicker
|
||||
key={cityId}
|
||||
lat={form.lat}
|
||||
lng={form.lng}
|
||||
status={form.status}
|
||||
onChange={handleLocationChange}
|
||||
/>
|
||||
) : (
|
||||
<p className="coords-hint">
|
||||
Location: {form.lat.toFixed(4)}, {form.lng.toFixed(4)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Status
|
||||
|
|
|
|||
|
|
@ -411,6 +411,23 @@ a {
|
|||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.location-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.location-picker-map {
|
||||
height: 240px;
|
||||
width: 100%;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.location-picker .coords-hint {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
Loading…
Reference in a new issue