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} />
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>`,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue