302 lines
11 KiB
JavaScript
302 lines
11 KiB
JavaScript
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 "../components/RulesModal";
|
||
import FeedTopBar from "../components/feed/FeedTopBar";
|
||
import FeedSearchBar from "../components/feed/FeedSearchBar";
|
||
import TypeFilterBar from "../components/feed/TypeFilterBar";
|
||
import FeedContent from "../components/feed/FeedContent";
|
||
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 } = 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 feedRef = useRef(null);
|
||
|
||
const isSearchView = new URLSearchParams(location.search).get("view") === "search";
|
||
const hasActiveFilters = searchQuery.trim().length > 0 || selectedCities.length > 0;
|
||
|
||
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); // triggers internal effect via selectedCities dep isn't needed here
|
||
} else {
|
||
feed.loadFeed();
|
||
}
|
||
}, [selectedCities, cityFeed, feed]);
|
||
|
||
const actions = useFeedActions({
|
||
refresh: refreshCurrentList,
|
||
loadMyPosts: myPosts.loadMyPosts,
|
||
onAuthRequired: () => modals.open("auth"),
|
||
isAuthenticated,
|
||
t,
|
||
});
|
||
|
||
// Загружаем лайки пользователя при авторизации
|
||
useEffect(() => {
|
||
if (isAuthenticated && token) {
|
||
loadFavorites().then(favoriteIds => setLiked(new Set(favoriteIds)));
|
||
} else {
|
||
setLiked(new Set());
|
||
}
|
||
}, [isAuthenticated, token, loadFavorites]);
|
||
|
||
// Открываем модалку аутентификации если пришли с лендинга
|
||
useEffect(() => {
|
||
if (location.state?.openAuth) {
|
||
modals.open("auth");
|
||
window.history.replaceState({}, document.title);
|
||
}
|
||
}, [location.state, modals]);
|
||
|
||
// Первичная загрузка ленты (дебаунс поиска), только когда нет фильтра по городам
|
||
useEffect(() => {
|
||
if (selectedCities.length > 0) return;
|
||
const timer = setTimeout(() => {
|
||
feed.loadFeed(0, searchQuery, false);
|
||
}, 400);
|
||
return () => clearTimeout(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [searchQuery, selectedCities]);
|
||
|
||
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
|
||
useEffect(() => {
|
||
if (activeNav === "myads" && isAuthenticated && user?.id) {
|
||
myPosts.loadMyPosts();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeNav, isAuthenticated, user?.id]);
|
||
|
||
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");
|
||
if (isSearchView) navigate("/feed");
|
||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||
}, [isSearchView, navigate]);
|
||
|
||
const handleSearchClick = useCallback(() => {
|
||
setActiveNav("home");
|
||
setSearchQuery("");
|
||
setSelectedCities([]);
|
||
if (!isSearchView) navigate("/feed?view=search");
|
||
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||
}, [isSearchView, navigate]);
|
||
|
||
// Карта с объявлениями — иконка добавлена, функционал ещё не реализован
|
||
const handleMapClick = useCallback(() => {}, []);
|
||
|
||
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 (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, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
|
||
|
||
const listLoading = activeNav === "myads" ? myPosts.loading : feed.loading;
|
||
const listError = activeNav === "myads" ? myPosts.error : feed.error;
|
||
|
||
return (
|
||
<div className={`f-app${isSearchView ? " f-search-view" : ""}`}>
|
||
<Sidebar
|
||
open={sidebarOpen}
|
||
activeNav={activeNav}
|
||
onClose={() => setSidebarOpen(false)}
|
||
onNavClick={setActiveNav}
|
||
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)} />
|
||
|
||
<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>
|
||
|
||
<TypeFilterBar value={typeFilter} onChange={setTypeFilter} t={t} />
|
||
|
||
<div className="f-wrap">
|
||
<div className="f-feed" ref={feedRef}>
|
||
<FeedContent
|
||
loading={listLoading}
|
||
cityLoading={cityFeed.cityLoading}
|
||
citySearchActive={selectedCities.length > 0}
|
||
error={listError}
|
||
filteredAds={filteredAds}
|
||
activeNav={activeNav}
|
||
liked={liked}
|
||
onLike={actions.toggleLike}
|
||
onEdit={handleEdit}
|
||
onDelete={actions.handleDelete}
|
||
onMarkFound={actions.handleMarkFound}
|
||
onImageClick={handleImageClick}
|
||
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={setActiveNav}
|
||
/>
|
||
</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}
|
||
/>
|
||
)}
|
||
|
||
<BottomNav
|
||
activeTab={isSearchView ? "search" : "home"}
|
||
hasFilters={hasActiveFilters}
|
||
profileData={profileData}
|
||
onHome={handleHomeClick}
|
||
onMap={handleMapClick}
|
||
onCreatePost={handleCreatePost}
|
||
onSearch={handleSearchClick}
|
||
onProfile={handleProfileClick}
|
||
t={t}
|
||
/>
|
||
</div>
|
||
);
|
||
} |