فهرست منبع

feat: MapPicker — выбор точки на карте + reverse geocoding

neyrogovnarik 1 ماه پیش
والد
کامیت
35aa83054c
2فایلهای تغییر یافته به همراه110 افزوده شده و 3 حذف شده
  1. 87 0
      frontend/src/components/MapPicker.tsx
  2. 23 3
      frontend/src/components/PlaceForm.tsx

+ 87 - 0
frontend/src/components/MapPicker.tsx

@@ -0,0 +1,87 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import L from 'leaflet'
+import 'leaflet/dist/leaflet.css'
+
+interface MapPickerProps {
+  onSelect: (lat: number, lng: number, address?: string) => void
+  onClose: () => void
+}
+
+export default function MapPicker({ onSelect, onClose }: MapPickerProps) {
+  const mapRef = useRef<HTMLDivElement>(null)
+  const markerRef = useRef<L.Marker | null>(null)
+  const mapInstanceRef = useRef<L.Map | null>(null)
+  const [lat, setLat] = useState(55.7558)
+  const [lng, setLng] = useState(37.6173)
+  const [loading, setLoading] = useState(false)
+
+  useEffect(() => {
+    if (!mapRef.current || mapInstanceRef.current) return
+
+    const map = L.map(mapRef.current, {
+      zoomControl: false,
+      attributionControl: false,
+    }).setView([lat, lng], 12)
+
+    L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+      maxZoom: 19,
+    }).addTo(map)
+
+    map.on('click', (e) => {
+      const newLat = e.latlng.lat
+      const newLng = e.latlng.lng
+      setLat(newLat)
+      setLng(newLng)
+      if (markerRef.current) markerRef.current.remove()
+      markerRef.current = L.marker([newLat, newLng]).addTo(map)
+    })
+
+    mapInstanceRef.current = map
+
+    return () => {
+      map.remove()
+      mapInstanceRef.current = null
+    }
+  }, [])
+
+  const handleConfirm = async () => {
+    setLoading(true)
+    try {
+      const res = await fetch(
+        `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&accept-language=ru`
+      )
+      const data = await res.json()
+      onSelect(lat, lng, data.display_name || undefined)
+    } catch {
+      onSelect(lat, lng)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-[60] flex flex-col bg-black/80 backdrop-blur-sm">
+      <div ref={mapRef} className="flex-1" />
+      <div className="flex items-center justify-between bg-[#1e293b] px-4 py-3">
+        <div className="text-sm text-white/60">
+          {lat.toFixed(6)}, {lng.toFixed(6)}
+        </div>
+        <div className="flex gap-3">
+          <button
+            onClick={onClose}
+            className="rounded-lg bg-white/10 px-4 py-2 text-sm text-white hover:bg-white/20 transition"
+          >
+            Отмена
+          </button>
+          <button
+            onClick={handleConfirm}
+            disabled={loading}
+            className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition disabled:opacity-50"
+          >
+            {loading ? 'Загрузка...' : 'Подтвердить'}
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 23 - 3
frontend/src/components/PlaceForm.tsx

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
 import { useRouter } from 'next/navigation'
 import { api } from '@/lib/api'
 import type { Tag, Feature } from '@/types'
+import MapPicker from './MapPicker'
 
 /** Пропсы компонента формы создания места/студии */
 interface PlaceFormProps {
@@ -39,6 +40,7 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
   const [minHours, setMinHours] = useState('1')
   const [coverFile, setCoverFile] = useState<File | null>(null)
   const [coverPreview, setCoverPreview] = useState('')
+  const [showMapPicker, setShowMapPicker] = useState(false)
 
   useEffect(() => {
     api.get<Tag[]>('/tags').then(setTags).catch(() => {})
@@ -54,6 +56,13 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
     }
   }, [coverPreview])
 
+  const handleMapPick = (pickLat: number, pickLng: number, pickAddress?: string) => {
+    setLat(pickLat.toFixed(6))
+    setLng(pickLng.toFixed(6))
+    if (pickAddress) setAddress(pickAddress)
+    setShowMapPicker(false)
+  }
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
     setLoading(true)
@@ -153,7 +162,16 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
         />
       </div>
 
-      <div className="grid grid-cols-2 gap-4">
+      <div className="grid grid-cols-3 gap-4">
+        <div className="flex items-end">
+          <button
+            type="button"
+            onClick={() => setShowMapPicker(true)}
+            className="w-full rounded-lg border border-white/10 bg-white/5 px-2 py-3 text-sm text-white/80 hover:bg-white/10 transition text-center leading-tight"
+          >
+            Указать<br />на карте
+          </button>
+        </div>
         <div>
           <label className="mb-1 block text-sm text-white/60">Широта</label>
           <input
@@ -161,7 +179,7 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
             step="any"
             value={lat}
             onChange={(e) => setLat(e.target.value)}
-            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500"
+            className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-3 text-white outline-none focus:border-green-500"
           />
         </div>
         <div>
@@ -171,7 +189,7 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
             step="any"
             value={lng}
             onChange={(e) => setLng(e.target.value)}
-            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-green-500"
+            className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-3 text-white outline-none focus:border-green-500"
           />
         </div>
       </div>
@@ -279,6 +297,8 @@ export default function PlaceForm({ type, onSuccess }: PlaceFormProps) {
       >
         {loading ? 'Сохранение...' : `Создать ${type === 'studio' ? 'студию' : 'место'}`}
       </button>
+
+      {showMapPicker && <MapPicker onSelect={handleMapPick} onClose={() => setShowMapPicker(false)} />}
     </form>
   )
 }