diff --git a/src/api/apiClient.jsx b/src/api/apiClient.jsx index 75202b5..e076f93 100644 --- a/src/api/apiClient.jsx +++ b/src/api/apiClient.jsx @@ -22,6 +22,11 @@ function clearTokens() { let refreshPromise = null; +let onSessionExpired = null; +export function setOnSessionExpired(cb) { + onSessionExpired = cb; +} + export async function refreshAccessToken() { if (refreshPromise) return refreshPromise; @@ -73,6 +78,7 @@ export async function apiFetch(url, options = {}) { res = await doFetch(newToken); } catch { clearTokens(); + if (onSessionExpired) onSessionExpired(); throw new Error("Session expired"); } } diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx new file mode 100644 index 0000000..8fdc0f4 --- /dev/null +++ b/src/components/MapView.jsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from "react"; +import { API_URL, getImageUrl } from "../config/authConstants"; + +const MARKER_COLORS = { + LOST: "#ff3b30", + LOSS: "#ff3b30", + FOUND: "#34c759", +}; + +function createMarkerLayout(color, imageUrl) { + const bgStyle = imageUrl + ? `background-image:url('${imageUrl}');background-size:cover;background-position:center;` + : ""; + + return window.ymaps.templateLayoutFactory.createClass( + `
`, + { + build() { + this.constructor.superclass.build.call(this); + }, + } + ); +} + +const FOCUS_ZOOM = 16; +const COORD_MATCH_TOLERANCE = 0.005; + +export default function MapView({ focusPostId, focusLat, focusLng, onPostClick }) { + const mapRef = useRef(null); + const ymapRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const onPostClickRef = useRef(onPostClick); + onPostClickRef.current = onPostClick; + + useEffect(() => { + let map; + const destroyed = { current: false }; + + const init = async () => { + if (!mapRef.current || destroyed.current) return; + + try { + const res = await fetch(`${API_URL}/api/v1/posts/map`); + if (!res.ok) throw new Error("Failed to fetch map markers"); + const markers = await res.json(); + + if (destroyed.current) return; + + map = new window.ymaps.Map(mapRef.current, { + center: [55.751574, 37.573856], + zoom: 11, + controls: ["zoomControl", "geolocationControl"], + }); + ymapRef.current = map; + + const placemarksById = new Map(); + + markers.forEach((m) => { + if (m.latitude == null || m.longitude == null) return; + + const color = MARKER_COLORS[m.type] || MARKER_COLORS.LOST; + const typeLabel = m.type === "FOUND" ? "Находка" : "Потеря"; + const imgSrc = m.imageUrl ? getImageUrl(m.imageUrl) : null; + const iconLayout = createMarkerLayout(color, imgSrc); + const coords = [m.latitude, m.longitude]; + + const placemark = new window.ymaps.Placemark( + coords, + {}, + { + iconLayout, + iconImageSize: [40, 40], + iconImageOffset: [-20, -20], + // Без явного iconShape карта не может корректно вычислить + // кликабельную область для кастомного HTML-лейаута — + // маркер рисуется, но клики по нему не срабатывают. + iconShape: { + type: "Circle", + coordinates: [20, 20], + radius: 20, + }, + hasBalloon: false, + hasHint: true, + hintContent: m.title || typeLabel, + } + ); + + map.geoObjects.add(placemark); + if (m.id != null) { + const postId = String(m.id); + placemarksById.set(postId, { placemark, coords }); + placemark.events.add("click", () => { + if (onPostClickRef.current) { + onPostClickRef.current(postId); + } + }); + } + }); + + let target = focusPostId != null + ? placemarksById.get(String(focusPostId)) + : null; + + if (!target && Number.isFinite(focusLat) && Number.isFinite(focusLng)) { + let best = null; + let bestDist = Infinity; + placemarksById.forEach((entry) => { + const d = + Math.pow(entry.coords[0] - focusLat, 2) + + Math.pow(entry.coords[1] - focusLng, 2); + if (d < bestDist) { + bestDist = d; + best = entry; + } + }); + if (best && bestDist < COORD_MATCH_TOLERANCE * COORD_MATCH_TOLERANCE) { + target = best; + } + } + + if (target) { + map.setCenter(target.coords, FOCUS_ZOOM); + } else if (placemarksById.size > 0) { + map.setBounds( + map.geoObjects.getBounds(), + { checkZoomRange: true, zoomMargin: 40 } + ); + } + } catch (e) { + if (!destroyed.current) setError(e.message); + } finally { + if (!destroyed.current) setLoading(false); + } + }; + + if (window.ymaps?.ready) { + window.ymaps.ready(init); + } + + return () => { + destroyed.current = true; + ymapRef.current?.destroy(); + ymapRef.current = null; + }; + }, [focusPostId, focusLat, focusLng]); + + return ( +