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:
Claudio Schaad 2026-07-28 21:12:51 +02:00
parent 11b6d493e7
commit 0b73929fea
5 changed files with 89 additions and 7 deletions

View 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='&copy; <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>
);
}

View file

@ -43,7 +43,7 @@ export function CityMap({ cities, focusCity }: CityMapProps) {
/> />
<InitialFocus focusCity={focusCity} /> <InitialFocus focusCity={focusCity} />
{cities.map((city) => ( {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> <Popup>
<div className="map-popup"> <div className="map-popup">
<strong>{city.name}</strong> <strong>{city.name}</strong>

View file

@ -1,8 +1,8 @@
import L from 'leaflet'; import L from 'leaflet';
import type { City } from '@citytracker/shared'; import type { CityStatus } from '@citytracker/shared';
export function cityMarkerIcon(city: City): L.DivIcon { export function cityMarkerIcon(status: CityStatus): L.DivIcon {
const color = city.status === 'visited' ? '#2f9e44' : '#1971c2'; const color = status === 'visited' ? '#2f9e44' : '#1971c2';
return L.divIcon({ return L.divIcon({
className: 'city-marker', className: 'city-marker',
html: `<span class="marker-pin" style="background:${color}"></span>`, html: `<span class="marker-pin" style="background:${color}"></span>`,

View file

@ -6,6 +6,7 @@ import { useAuth } from '../context/AuthContext';
import { useCity, useDeleteCity, useUploadPhoto, useDeletePhoto } from '../hooks/useCities'; import { useCity, useDeleteCity, useUploadPhoto, useDeletePhoto } from '../hooks/useCities';
import * as citiesApi from '../api/cities'; import * as citiesApi from '../api/cities';
import { CitySearchInput } from '../components/city/CitySearchInput'; import { CitySearchInput } from '../components/city/CitySearchInput';
import { LocationPicker } from '../components/city/LocationPicker';
import { LinkListEditor } from '../components/city/LinkListEditor'; import { LinkListEditor } from '../components/city/LinkListEditor';
import { ParticipantPicker } from '../components/city/ParticipantPicker'; import { ParticipantPicker } from '../components/city/ParticipantPicker';
import { PhotoUploader } from '../components/city/PhotoUploader'; 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) { function handleGeocodeSelect(result: GeocodeResult) {
setForm((prev) => { setForm((prev) => {
const next: FormState = { const next: FormState = {
@ -249,10 +258,22 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
</label> </label>
</div> </div>
{form.lat !== null && form.lng !== null && ( {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"> <p className="coords-hint">
Location: {form.lat.toFixed(4)}, {form.lng.toFixed(4)} Location: {form.lat.toFixed(4)}, {form.lng.toFixed(4)}
</p> </p>
)} )}
</>
)}
<div className="form-row"> <div className="form-row">
<label> <label>
Status Status

View file

@ -411,6 +411,23 @@ a {
font-size: 0.85rem; 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 { .form-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;