Files
nahodka-front/src/pages/MainFeed.jsx
2026-08-27 01:30:03 +03:00

416 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "../styles/index.css";
import { getImageUrl } from "../config/authConstants";
import Sidebar from "../components/Sidebar";
import BottomNav from "../components/nav/BottomNav";
import RightSidebar from "../components/RightSidebar";
import CreateUpdatePost from "./CreateUpdatePost";
import ImageViewer from "../components/ImageViewer";
import CityFilter from "../components/CityFilter";
import { ProfileModal } from "../components/profile";
import AuthModal from "../components/AuthModal";
import RulesModal from "./RulesModal";
import FeedTopBar from "../components/feed/FeedTopBar";
import FeedSearchBar from "../components/feed/FeedSearchBar";
import FeedContent from "../components/feed/FeedContent";
import MapView from "../components/MapView";
import PostDetailModal from "../components/PostDetailModal";
import { useAuthContext } from "../context/AuthContext";
import { useCities } from "../hooks/useCities";
import { useFeedPosts } from "../hooks/useFeedPosts";
import { useMyPosts } from "../hooks/useMyPosts";
import { useCityFeed } from "../hooks/useCityFeed";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
import { useModals } from "../hooks/useModals";
import { useFeedActions } from "../hooks/useFeedActions";
import { useFavorites } from "../hooks/useFavorites";
import { useTranslation } from 'react-i18next';
/**
* MainFeed — корневой компонент приложения.
* Композиция хуков состояния + презентационных компонентов ленты.
*/
export default function MainFeed() {
const navigate = useNavigate();
const location = useLocation();
const { user, token, isAuthenticated } = useAuthContext();
const { cities } = useCities();
const { loadFavorites, loadFavoritePosts, error: favoritesError } = useFavorites();
const { t } = useTranslation();
const modals = useModals();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeNav, setActiveNav] = useState("home");
const [typeFilter, setTypeFilter] = useState(null);
const [selectedCities, setSelectedCities] = useState([]);
const [searchQuery, setSearchQuery] = useState("");
const [liked, setLiked] = useState(new Set());
const [likeCountAdj, setLikeCountAdj] = useState({});
const [favoritePosts, setFavoritePosts] = useState([]);
const [favoritesLoading, setFavoritesLoading] = useState(false);
const feedRef = useRef(null);
const isSearchView = new URLSearchParams(location.search).get("view") === "search";
const isMapView = new URLSearchParams(location.search).get("view") === "map";
// параметры фокуса карты на конкретном объявлении (?view=map&post=ID&lat=..&lng=..)
const mapFocusParams = new URLSearchParams(location.search);
const focusPostId = isMapView ? mapFocusParams.get("post") : null;
const focusLat = parseFloat(mapFocusParams.get("lat"));
const focusLng = parseFloat(mapFocusParams.get("lng"));
const hasActiveFilters = searchQuery.trim().length > 0 || selectedCities.length > 0;
const hasSearchParams = searchQuery.trim().length > 0 || selectedCities.length > 0;
const searchPrompt = isSearchView && !hasSearchParams;
const feed = useFeedPosts();
const myPosts = useMyPosts(user?.id);
const cityFeed = useCityFeed(selectedCities);
const profileData = useMemo(() => {
if (!user) return {};
const nameParts = (user.name || "").trim().split(/\s+/);
const firstName = user.firstName || nameParts[0] || "";
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
return {
login: user.login || "",
firstName,
lastName,
email: user.email || "",
phone: user.phone || "",
avatar: getImageUrl(user.avatar),
id: user.id,
};
}, [user]);
const refreshCurrentList = useCallback(() => {
if (selectedCities.length > 0) {
cityFeed.searchCities(selectedCities);
} else {
feed.loadFeed();
}
}, [selectedCities, cityFeed, feed]);
const actions = useFeedActions({
refresh: refreshCurrentList,
loadMyPosts: myPosts.loadMyPosts,
onAuthRequired: () => modals.open("auth"),
isAuthenticated,
t,
setLiked,
setLikeCountAdj,
});
// Загружаем лайки пользователя при авторизации
useEffect(() => {
if (isAuthenticated && token) {
loadFavorites().then(favoriteIds => {
const s = new Set(favoriteIds);
actions.syncLiked(s);
setLiked(s);
});
} else {
actions.syncLiked(new Set());
setLiked(new Set());
}
setLikeCountAdj({});
}, [isAuthenticated, token, loadFavorites]);
// Открываем модалку аутентификации если пришли с лендинга
useEffect(() => {
if (location.state?.openAuth) {
modals.open("auth");
window.history.replaceState({}, document.title);
}
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
// Загрузка основной ленты (не в режиме поиска)
useEffect(() => {
if (!isSearchView && selectedCities.length === 0 && !searchQuery.trim()) {
feed.loadFeed(0, "", false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSearchView]);
// Дебаунс поиска: только когда в search view и есть параметры
useEffect(() => {
if (!isSearchView) return;
if (!hasSearchParams) return;
const timer = setTimeout(() => {
feed.loadFeed(0, searchQuery, false);
}, 400);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedCities, isSearchView]);
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
useEffect(() => {
if (activeNav === "myads" && isAuthenticated && user?.id) {
myPosts.loadMyPosts();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNav, isAuthenticated, user?.id]);
// Загрузка избранных постов при переходе во вкладку «Избранное»
useEffect(() => {
if (activeNav !== "favorites") return;
if (!isAuthenticated || !token) return;
let cancelled = false;
setFavoritesLoading(true);
loadFavoritePosts().then(posts => {
if (cancelled) return;
setFavoritePosts(posts);
setFavoritesLoading(false);
});
return () => { cancelled = true; };
}, [activeNav, isAuthenticated, token, loadFavoritePosts]);
const sentinelRef = useInfiniteScroll(
() => feed.loadFeed(feed.page + 1, searchQuery, true),
activeNav === "home" && selectedCities.length === 0 && feed.hasMore && !feed.loading && !feed.loadingMore
);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) modals.open("viewer", { images, index, ad });
}, [modals]);
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) modals.open("auth");
else modals.open("create");
}, [isAuthenticated, modals]);
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) modals.open("auth");
else modals.open("edit", ad);
}, [isAuthenticated, modals]);
const handleHomeClick = useCallback(() => {
setActiveNav("home");
setSearchQuery("");
setSelectedCities([]);
if (isSearchView || isMapView) navigate("/feed");
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [isSearchView, isMapView, navigate]);
const handleSearchClick = useCallback(() => {
setActiveNav("home");
setSearchQuery("");
setSelectedCities([]);
if (!isSearchView) navigate("/feed?view=search");
else if (isMapView) navigate("/feed?view=search");
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [isSearchView, isMapView, navigate]);
// Карта с объявлениями
const handleMapClick = useCallback(() => {
setActiveNav("map");
if (!isMapView) navigate("/feed?view=map");
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 });
if (ad.latitude != null && ad.longitude != null) {
params.set("lat", ad.latitude);
params.set("lng", ad.longitude);
}
setActiveNav("map");
navigate(`/feed?${params.toString()}`);
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [navigate]);
const handleProfileClick = useCallback(() => {
if (!isAuthenticated) modals.open("auth");
else modals.open("profile");
}, [isAuthenticated, modals]);
const filteredAds = useMemo(() => {
let result;
if (activeNav === "myads") {
result = myPosts.myAds;
} else if (activeNav === "favorites") {
result = favoritePosts;
} else if (selectedCities.length > 0) {
result = cityFeed.cityAds;
} else {
result = feed.ads;
}
if (activeNav === "items") {
result = result.filter(ad => ["Вещь", "Другое"].includes(ad.category));
} else if (activeNav === "pets") {
result = result.filter(ad => ad.category === "Питомец");
}
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
return result;
}, [activeNav, myPosts.myAds, favoritePosts, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
const listLoading = activeNav === "myads"
? myPosts.loading
: activeNav === "favorites"
? favoritesLoading
: feed.loading;
const listError = activeNav === "myads"
? myPosts.error
: activeNav === "favorites"
? favoritesError
: feed.error;
return (
<div className={`f-app${isSearchView ? " f-search-view" : ""}${isMapView ? " f-map-view" : ""}`}>
<Sidebar
open={sidebarOpen}
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
onNavClick={(key) => {
setActiveNav(key);
setSearchQuery("");
setSelectedCities([]);
if (isMapView || isSearchView) navigate("/feed");
}}
isAuthenticated={isAuthenticated}
onLoginClick={() => modals.open("auth")}
onRulesClick={() => modals.open("rules")}
onReviewsClick={() => navigate("/reviews")}
onCreatePost={handleCreatePost}
onProfileClick={() => modals.open("profile")}
profileData={profileData}
/>
<div className="f-main f-main-feed">
<FeedTopBar
onMenuClick={() => setSidebarOpen(v => !v)}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
/>
<div className="f-search-panel">
<FeedSearchBar value={searchQuery} onChange={setSearchQuery} t={t} />
{activeNav === "home" && (
<div className="f-city-filter-wrap">
<CityFilter cities={cities} value={selectedCities} onChange={setSelectedCities} />
</div>
)}
</div>
<div className="f-wrap">
{isMapView ? (
<MapView focusPostId={focusPostId} focusLat={focusLat} focusLng={focusLng} onPostClick={handleMapPostClick} />
) : (
<>
<div className="f-feed" ref={feedRef}>
<FeedContent
loading={listLoading}
cityLoading={cityFeed.cityLoading}
citySearchActive={selectedCities.length > 0}
error={listError}
filteredAds={filteredAds}
activeNav={activeNav}
searchPrompt={searchPrompt}
liked={liked}
likeCountAdj={likeCountAdj}
onLike={actions.toggleLike}
onEdit={handleEdit}
onDelete={actions.handleDelete}
onMarkFound={actions.handleMarkFound}
onImageClick={handleImageClick}
onLocationClick={handleLocationClick}
onAuthRequired={() => modals.open("auth")}
t={t}
/>
{activeNav === "home" && selectedCities.length === 0 && !feed.loading && (
<div ref={sentinelRef} style={{ height: "1px" }} />
)}
{feed.loadingMore && (
<div className="f-center" style={{ padding: "1rem" }}>
<div className="f-spinner" />
</div>
)}
</div>
<RightSidebar
ads={filteredAds}
profileData={profileData}
isAuthenticated={isAuthenticated}
onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")}
onNavClick={(key) => {
setActiveNav(key);
setSearchQuery("");
setSelectedCities([]);
if (isMapView || isSearchView) navigate("/feed");
}}
/>
</>
)}
</div>
</div>
{modals.isOpen("create") && isAuthenticated && (
<CreateUpdatePost onClose={modals.close} onSuccess={actions.handleNewAd} />
)}
{modals.isOpen("edit") && isAuthenticated && (
<CreateUpdatePost
initialData={modals.modal.payload}
onClose={modals.close}
onSuccess={actions.handleEditSave}
/>
)}
{modals.isOpen("profile") && isAuthenticated && (
<ProfileModal onClose={modals.close} initialData={profileData} />
)}
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
{modals.isOpen("viewer") && (
<ImageViewer
images={modals.modal.payload.images}
initialIndex={modals.modal.payload.index}
onClose={modals.close}
postData={modals.modal.payload.ad}
/>
)}
{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
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
hasFilters={hasActiveFilters}
profileData={profileData}
onHome={handleHomeClick}
onMap={handleMapClick}
onCreatePost={handleCreatePost}
onSearch={handleSearchClick}
onProfile={handleProfileClick}
t={t}
/>
</div>
);
}