added post modal for feed on map
This commit is contained in:
178
src/components/MapView.jsx
Normal file
178
src/components/MapView.jsx
Normal file
@@ -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(
|
||||
`<div style="
|
||||
width:40px;height:40px;
|
||||
border-radius:50%;
|
||||
border:3px solid ${color};
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.35);
|
||||
overflow:hidden;
|
||||
background:${imageUrl ? "transparent" : color};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;
|
||||
">
|
||||
<div style="
|
||||
width:100%;height:100%;border-radius:50%;
|
||||
${bgStyle}
|
||||
"></div>
|
||||
</div>`,
|
||||
{
|
||||
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 (
|
||||
<div className="f-mapview">
|
||||
{loading && (
|
||||
<div className="f-mapview-status">
|
||||
<div className="f-spinner" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="f-mapview-status f-mapview-error">
|
||||
Ошибка загрузки карты
|
||||
</div>
|
||||
)}
|
||||
<div ref={mapRef} className="f-mapview-canvas" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/PostDetailModal.jsx
Normal file
99
src/components/PostDetailModal.jsx
Normal file
@@ -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 (
|
||||
<div className="f-modal-bg" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="f-modal f-post-detail-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="f-close-btn f-post-detail-close" onClick={onClose} aria-label={t("common.close")}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
|
||||
{loading && (
|
||||
<div className="f-post-detail-status">
|
||||
<div className="f-spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="f-post-detail-status f-post-detail-error">
|
||||
{t("common.error")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post && (
|
||||
<PostOnFeed
|
||||
ad={post}
|
||||
liked={liked}
|
||||
likeCountAdj={likeCountAdj}
|
||||
onLike={handleLike}
|
||||
isOwner={isOwner}
|
||||
onImageClick={onImageClick}
|
||||
onLocationClick={onLocationClick}
|
||||
onAuthRequired={onAuthRequired}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/nav/BottomNav.jsx
Normal file
103
src/components/nav/BottomNav.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import ProfileAvatar from "../profile/ProfileAvatar";
|
||||
|
||||
const HomeIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 10.5 9-7.5 9 7.5v9a2 2 0 0 1-2 2h-4.5v-6h-5v6H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MapIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" />
|
||||
<path d="M15 5.764v15" />
|
||||
<path d="M9 3.236v15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SearchIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<nav className="f-bottom-nav" aria-label="bottom-nav">
|
||||
<button
|
||||
type="button"
|
||||
className={`f-bn-item${activeTab === "home" ? " active" : ""}`}
|
||||
onClick={onHome}
|
||||
aria-label={t("nav.home")}
|
||||
title={t("nav.home")}
|
||||
>
|
||||
<HomeIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`f-bn-item${activeTab === "map" ? " active" : ""}`}
|
||||
onClick={onMap}
|
||||
aria-label={t("nav.map")}
|
||||
title={t("nav.map")}
|
||||
>
|
||||
<MapIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="f-bn-item f-bn-create"
|
||||
onClick={onCreatePost}
|
||||
aria-label={t("nav.create")}
|
||||
title={t("nav.create")}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`f-bn-item${activeTab === "search" ? " active" : ""}`}
|
||||
onClick={onSearch}
|
||||
aria-label={t("nav.search")}
|
||||
title={t("nav.search")}
|
||||
>
|
||||
<SearchIcon />
|
||||
{hasFilters && <span className="f-bn-dot" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="f-bn-item f-bn-profile"
|
||||
onClick={onProfile}
|
||||
aria-label={t("nav.profile")}
|
||||
title={t("nav.profile")}
|
||||
>
|
||||
<ProfileAvatar avatar={profileData?.avatar} name={profileName} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user