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 ( +
+ {loading && ( +
+
+
+ )} + {error && ( +
+ Ошибка загрузки карты +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/PostDetailModal.jsx b/src/components/PostDetailModal.jsx new file mode 100644 index 0000000..281f224 --- /dev/null +++ b/src/components/PostDetailModal.jsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { API_URL } from "../config/authConstants"; +import { apiFetch } from "../api/apiClient"; +import { useAuthContext } from "../context/AuthContext"; +import { useFavorites } from "../hooks/useFavorites"; +import PostOnFeed from "../pages/PostOnFeed"; +import CloseIcon from "./icons/CloseIcon"; + +export default function PostDetailModal({ postId, onClose, onImageClick, onLocationClick, onAuthRequired }) { + const { t } = useTranslation(); + const { user, isAuthenticated } = useAuthContext(); + const { toggleFavorite } = useFavorites(); + const [post, setPost] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [liked, setLiked] = useState(false); + const [likeCountAdj, setLikeCountAdj] = useState(0); + + useEffect(() => { + if (!postId) return; + let cancelled = false; + + const fetchPost = async () => { + setLoading(true); + setError(null); + try { + const res = await apiFetch(`${API_URL}/api/v1/posts/detail/${postId}`); + if (!res.ok) throw new Error("Failed to load post"); + const data = await res.json(); + if (!cancelled) { + setPost({ ...data, type: data.type ? data.type.toLowerCase() : "loss" }); + setLiked(!!data.liked); + setLikeCountAdj(0); + } + } catch (e) { + if (!cancelled) setError(e.message); + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchPost(); + return () => { cancelled = true; }; + }, [postId]); + + const handleLike = async (id) => { + if (!isAuthenticated) { + if (onAuthRequired) onAuthRequired(); + return; + } + const prev = liked; + setLiked((v) => !v); + setLikeCountAdj((v) => (prev ? v - 1 : v + 1)); + const result = await toggleFavorite(id); + if (!result.success) { + setLiked(prev); + setLikeCountAdj((v) => (prev ? v + 1 : v - 1)); + } + }; + + const isOwner = post && user && (post.authorId === user.id || post.userId === user.id); + + return ( +
e.target === e.currentTarget && onClose()}> +
e.stopPropagation()}> + + + {loading && ( +
+
+
+ )} + + {error && ( +
+ {t("common.error")} +
+ )} + + {post && ( + + )} +
+
+ ); +} diff --git a/src/components/nav/BottomNav.jsx b/src/components/nav/BottomNav.jsx new file mode 100644 index 0000000..3a3d21b --- /dev/null +++ b/src/components/nav/BottomNav.jsx @@ -0,0 +1,103 @@ +import ProfileAvatar from "../profile/ProfileAvatar"; + +const HomeIcon = () => ( + + + +); + +const MapIcon = () => ( + + + + + +); + +const PlusIcon = () => ( + + + + +); + +const SearchIcon = () => ( + + + + +); + +/** + * BottomNav — нижняя навигационная панель в стиле Instagram. + * 5 вкладок: Главная / Карта / Разместить / Поиск / Профиль. + * Активная вкладка определяется по текущему роуту (activeTab сверху). + */ +export default function BottomNav({ + activeTab = "home", + hasFilters = false, + profileData, + onHome, + onMap, + onCreatePost, + onSearch, + onProfile, + t, +}) { + const profileName = `${profileData?.firstName || ""} ${profileData?.lastName || ""}`.trim(); + + return ( + + ); +} diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index a342873..f36552f 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -1,6 +1,6 @@ // src/context/AuthContext.jsx -import { createContext, useContext, useState, useCallback } from "react"; -import { saveTokens, clearTokens, getAccessToken } from "../api/apiClient"; +import { createContext, useContext, useState, useCallback, useEffect } from "react"; +import { saveTokens, clearTokens, getAccessToken, setOnSessionExpired } from "../api/apiClient"; const AuthContext = createContext(null); @@ -51,6 +51,14 @@ export function AuthProvider({ children }) { setUser(userData); }, []); + useEffect(() => { + setOnSessionExpired(() => { + setUser(null); + setToken(null); + }); + return () => setOnSessionExpired(null); + }, []); + return ( { setActiveNav("home"); + setSearchQuery(""); + setSelectedCities([]); if (isSearchView || isMapView) navigate("/feed"); feedRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, [isSearchView, isMapView, navigate]); @@ -206,6 +209,10 @@ export default function MainFeed() { feedRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, [isMapView, navigate]); + const handleMapPostClick = useCallback((postId) => { + modals.open("postDetail", { postId }); + }, [modals]); + // Клик по локации на карточке — карта с фокусом на этом объявлении const handleLocationClick = useCallback((ad) => { const params = new URLSearchParams({ view: "map", post: ad.id }); @@ -266,6 +273,8 @@ export default function MainFeed() { onClose={() => setSidebarOpen(false)} onNavClick={(key) => { setActiveNav(key); + setSearchQuery(""); + setSelectedCities([]); if (isMapView || isSearchView) navigate("/feed"); }} isAuthenticated={isAuthenticated} @@ -296,7 +305,7 @@ export default function MainFeed() {
{isMapView ? ( - + ) : ( <>
@@ -336,6 +345,8 @@ export default function MainFeed() { onLoginClick={() => modals.open("auth")} onNavClick={(key) => { setActiveNav(key); + setSearchQuery(""); + setSelectedCities([]); if (isMapView || isSearchView) navigate("/feed"); }} /> @@ -373,6 +384,21 @@ export default function MainFeed() { /> )} + {modals.isOpen("postDetail") && ( + { + modals.open("viewer", { images, index, ad }); + }} + onLocationClick={(ad) => { + modals.close(); + handleLocationClick(ad); + }} + onAuthRequired={() => modals.open("auth")} + /> + )} + + {!compact && (
+ )}
+ {!compact && (
{address && (
)}
+ )}
@@ -176,12 +180,10 @@ export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, on )}
- {!isOwner && !isClosed && ( - - )} + {onMarkFound && isOwner && !isClosed && (