Merge pull request #57 from SlimusMinus/add-post_modal_map
added post modal for feed on map
This commit is contained in:
@@ -22,6 +22,11 @@ function clearTokens() {
|
|||||||
|
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
|
|
||||||
|
let onSessionExpired = null;
|
||||||
|
export function setOnSessionExpired(cb) {
|
||||||
|
onSessionExpired = cb;
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken() {
|
export async function refreshAccessToken() {
|
||||||
if (refreshPromise) return refreshPromise;
|
if (refreshPromise) return refreshPromise;
|
||||||
|
|
||||||
@@ -73,6 +78,7 @@ export async function apiFetch(url, options = {}) {
|
|||||||
res = await doFetch(newToken);
|
res = await doFetch(newToken);
|
||||||
} catch {
|
} catch {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
|
if (onSessionExpired) onSessionExpired();
|
||||||
throw new Error("Session expired");
|
throw new Error("Session expired");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/context/AuthContext.jsx
|
// src/context/AuthContext.jsx
|
||||||
import { createContext, useContext, useState, useCallback } from "react";
|
import { createContext, useContext, useState, useCallback, useEffect } from "react";
|
||||||
import { saveTokens, clearTokens, getAccessToken } from "../api/apiClient";
|
import { saveTokens, clearTokens, getAccessToken, setOnSessionExpired } from "../api/apiClient";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
@@ -51,6 +51,14 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(userData);
|
setUser(userData);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOnSessionExpired(() => {
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
});
|
||||||
|
return () => setOnSessionExpired(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import FeedSearchBar from "../components/feed/FeedSearchBar";
|
|||||||
|
|
||||||
import FeedContent from "../components/feed/FeedContent";
|
import FeedContent from "../components/feed/FeedContent";
|
||||||
import MapView from "../components/MapView";
|
import MapView from "../components/MapView";
|
||||||
|
import PostDetailModal from "../components/PostDetailModal";
|
||||||
import { useAuthContext } from "../context/AuthContext";
|
import { useAuthContext } from "../context/AuthContext";
|
||||||
import { useCities } from "../hooks/useCities";
|
import { useCities } from "../hooks/useCities";
|
||||||
import { useFeedPosts } from "../hooks/useFeedPosts";
|
import { useFeedPosts } from "../hooks/useFeedPosts";
|
||||||
@@ -186,6 +187,8 @@ export default function MainFeed() {
|
|||||||
|
|
||||||
const handleHomeClick = useCallback(() => {
|
const handleHomeClick = useCallback(() => {
|
||||||
setActiveNav("home");
|
setActiveNav("home");
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedCities([]);
|
||||||
if (isSearchView || isMapView) navigate("/feed");
|
if (isSearchView || isMapView) navigate("/feed");
|
||||||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, [isSearchView, isMapView, navigate]);
|
}, [isSearchView, isMapView, navigate]);
|
||||||
@@ -206,6 +209,10 @@ export default function MainFeed() {
|
|||||||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, [isMapView, navigate]);
|
}, [isMapView, navigate]);
|
||||||
|
|
||||||
|
const handleMapPostClick = useCallback((postId) => {
|
||||||
|
modals.open("postDetail", { postId });
|
||||||
|
}, [modals]);
|
||||||
|
|
||||||
// Клик по локации на карточке — карта с фокусом на этом объявлении
|
// Клик по локации на карточке — карта с фокусом на этом объявлении
|
||||||
const handleLocationClick = useCallback((ad) => {
|
const handleLocationClick = useCallback((ad) => {
|
||||||
const params = new URLSearchParams({ view: "map", post: ad.id });
|
const params = new URLSearchParams({ view: "map", post: ad.id });
|
||||||
@@ -266,6 +273,8 @@ export default function MainFeed() {
|
|||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
onNavClick={(key) => {
|
onNavClick={(key) => {
|
||||||
setActiveNav(key);
|
setActiveNav(key);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedCities([]);
|
||||||
if (isMapView || isSearchView) navigate("/feed");
|
if (isMapView || isSearchView) navigate("/feed");
|
||||||
}}
|
}}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
@@ -296,7 +305,7 @@ export default function MainFeed() {
|
|||||||
|
|
||||||
<div className="f-wrap">
|
<div className="f-wrap">
|
||||||
{isMapView ? (
|
{isMapView ? (
|
||||||
<MapView focusPostId={focusPostId} focusLat={focusLat} focusLng={focusLng} />
|
<MapView focusPostId={focusPostId} focusLat={focusLat} focusLng={focusLng} onPostClick={handleMapPostClick} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="f-feed" ref={feedRef}>
|
<div className="f-feed" ref={feedRef}>
|
||||||
@@ -336,6 +345,8 @@ export default function MainFeed() {
|
|||||||
onLoginClick={() => modals.open("auth")}
|
onLoginClick={() => modals.open("auth")}
|
||||||
onNavClick={(key) => {
|
onNavClick={(key) => {
|
||||||
setActiveNav(key);
|
setActiveNav(key);
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedCities([]);
|
||||||
if (isMapView || isSearchView) navigate("/feed");
|
if (isMapView || isSearchView) navigate("/feed");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -373,6 +384,21 @@ export default function MainFeed() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modals.isOpen("postDetail") && (
|
||||||
|
<PostDetailModal
|
||||||
|
postId={modals.modal.payload.postId}
|
||||||
|
onClose={modals.close}
|
||||||
|
onImageClick={(images, index, ad) => {
|
||||||
|
modals.open("viewer", { images, index, ad });
|
||||||
|
}}
|
||||||
|
onLocationClick={(ad) => {
|
||||||
|
modals.close();
|
||||||
|
handleLocationClick(ad);
|
||||||
|
}}
|
||||||
|
onAuthRequired={() => modals.open("auth")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<BottomNav
|
<BottomNav
|
||||||
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
|
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
|
||||||
hasFilters={hasActiveFilters}
|
hasFilters={hasActiveFilters}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import DateCalendarPopup from "../components/DateCalendarPopup";
|
|||||||
import Toast from "../components/Toast";
|
import Toast from "../components/Toast";
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onLocationClick, onAuthRequired }) {
|
export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onLocationClick, onAuthRequired, compact }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showCalendar, setShowCalendar] = useState(false);
|
const [showCalendar, setShowCalendar] = useState(false);
|
||||||
const [descExpanded, setDescExpanded] = useState(false);
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
@@ -83,6 +83,7 @@ export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, on
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
<div className="f-card-actions">
|
<div className="f-card-actions">
|
||||||
<div className="f-card-actions-left">
|
<div className="f-card-actions-left">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -135,6 +136,7 @@ export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, on
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="f-card-caption">
|
<div className="f-card-caption">
|
||||||
<span
|
<span
|
||||||
@@ -146,6 +148,7 @@ export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, on
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
<div className="f-card-meta">
|
<div className="f-card-meta">
|
||||||
{address && (
|
{address && (
|
||||||
<div
|
<div
|
||||||
@@ -165,6 +168,7 @@ export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, on
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="f-card-foot">
|
<div className="f-card-foot">
|
||||||
<div className="f-card-foot-left">
|
<div className="f-card-foot-left">
|
||||||
@@ -176,12 +180,10 @@ export default function PostOnFeed({ ad, liked, likeCountAdj, onLike, onEdit, on
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="f-card-foot-right">
|
<div className="f-card-foot-right">
|
||||||
{!isOwner && !isClosed && (
|
|
||||||
<button type="button" className="f-card-contact"
|
<button type="button" className="f-card-contact"
|
||||||
onClick={handleContact}>
|
onClick={handleContact}>
|
||||||
{t('postCard.write')}
|
{t('postCard.write')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
{onMarkFound && isOwner && !isClosed && (
|
{onMarkFound && isOwner && !isClosed && (
|
||||||
<button type="button" className="f-card-found-btn"
|
<button type="button" className="f-card-found-btn"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
|
|||||||
@@ -219,15 +219,15 @@
|
|||||||
|
|
||||||
/* ── Footer (phone + owner actions) ── */
|
/* ── Footer (phone + owner actions) ── */
|
||||||
.f-card-foot {
|
.f-card-foot {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; flex-direction: row; align-items: center; justify-content: center;
|
||||||
gap: 8px; padding: 10px 16px 12px;
|
gap: 10px; padding: 10px 16px 12px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.f-card-foot-left { display: flex; align-items: center; min-width: 0; }
|
.f-card-foot-left { display: flex; align-items: center; }
|
||||||
.f-card-foot-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
.f-card-foot-right { display: flex; align-items: center; }
|
||||||
.f-card-phone {
|
.f-card-phone {
|
||||||
color: var(--teal); font-size: .85rem; font-weight: 700;
|
color: var(--teal); font-size: .75rem; font-weight: 700;
|
||||||
text-decoration: none; display: flex; align-items: center; gap: 6px;
|
text-decoration: none; display: flex; align-items: center; gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.f-card-date { color: var(--muted2); font-size: .75rem; }
|
.f-card-date { color: var(--muted2); font-size: .75rem; }
|
||||||
|
|
||||||
@@ -235,10 +235,11 @@
|
|||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; gap: 5px;
|
||||||
background: rgba(var(--teal-rgb), .1);
|
background: rgba(var(--teal-rgb), .1);
|
||||||
border: 1px solid rgba(var(--teal-rgb), .3);
|
border: 1px solid rgba(var(--teal-rgb), .3);
|
||||||
border-radius: 999px; padding: 12px 36px;
|
border-radius: 999px; padding: 6px 20px;
|
||||||
cursor: pointer; font-family: var(--font);
|
cursor: pointer; font-family: var(--font);
|
||||||
font-size: 1.05rem; font-weight: 700;
|
font-size: .75rem; font-weight: 700;
|
||||||
color: var(--teal); transition: all .2s;
|
color: var(--teal); transition: all .2s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.f-card-contact:hover {
|
.f-card-contact:hover {
|
||||||
background: rgba(var(--teal-rgb), .18);
|
background: rgba(var(--teal-rgb), .18);
|
||||||
@@ -389,8 +390,7 @@
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.f-card-contact {
|
.f-card-contact {
|
||||||
padding: 10px 40px;
|
padding: 5px 16px;
|
||||||
font-size: .95rem;
|
font-size: .7rem;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100vh;
|
height: 100vh; height: 100dvh;
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ── App Shell (Instagram-style) ── */
|
/* ── App Shell (Instagram-style) ── */
|
||||||
.f-app {
|
.f-app {
|
||||||
display: flex; height: 100vh;
|
display: flex; height: 100vh; height: 100dvh;
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
overflow: hidden; font-family: var(--font); position: relative;
|
overflow: hidden; font-family: var(--font); position: relative;
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
/* ── Main Column ── */
|
/* ── Main Column ── */
|
||||||
.f-main {
|
.f-main {
|
||||||
flex: 1; display: flex; flex-direction: column;
|
flex: 1; display: flex; flex-direction: column;
|
||||||
min-width: 0; height: 100vh; overflow: hidden;
|
min-width: 0; height: 100vh; height: 100dvh; overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Topbar (mobile only; desktop uses the nav sidebar) ── */
|
/* ── Topbar (mobile only; desktop uses the nav sidebar) ── */
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
position: sticky; top: 0; z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
.f-menu-btn {
|
.f-menu-btn {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.f-topbar-dropdown-wrap .f-topbar {
|
.f-topbar-dropdown-wrap .f-topbar {
|
||||||
position: sticky; top: 0; z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
.f-topbar-dropdown-btn {
|
.f-topbar-dropdown-btn {
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
|||||||
@@ -274,6 +274,10 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.f-modal-bg:has(.f-post-detail-modal) {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.f-modal-bg.auth-modal-bg {
|
.f-modal-bg.auth-modal-bg {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -392,3 +396,62 @@
|
|||||||
.f-modal-sm {
|
.f-modal-sm {
|
||||||
width: 420px;
|
width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.f-post-detail-modal {
|
||||||
|
width: 480px;
|
||||||
|
max-width: 95vw;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
.f-post-detail-modal .f-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.f-post-detail-modal .f-card-head {
|
||||||
|
border-radius: 24px 24px 0 0;
|
||||||
|
}
|
||||||
|
.f-post-detail-modal .f-card-head {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.f-post-detail-modal .f-card-img {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.f-post-detail-modal .f-card-img img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.f-post-detail-modal .f-card-caption {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
.f-post-detail-modal .f-card-foot {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
.f-post-detail-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.f-post-detail-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
.f-post-detail-error {
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.f-modal.f-post-detail-modal {
|
||||||
|
width: 67%;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 0;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user