import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import "../styles/index.css"; import { API_URL } from "../config/authConstants"; import Sidebar from "../components/Sidebar"; import RightSidebar from "../components/RightSidebar"; import PostOnFeed from "../components/PostOnFeed"; import CreateUpdatePost from "../components/CreateUpdatePost"; import ImageViewer from "../components/ImageViewer"; import MenuIcon from "../components/icons/MenuIcon"; import ProfileAvatar from "../components/profile/ProfileAvatar"; import { ProfileModal } from "../components/profile"; import AuthModal from "../components/AuthModal"; import RulesModal from "../components/RulesModal"; import { useAuthContext } from "../context/AuthContext"; import { useDeletePost } from "../hooks/useDeletePost"; import { useFavorites } from "../hooks/useFavorites"; import { usePostStatus } from "../hooks/usePostStatus"; /** * MainFeed — корневой компонент приложения. * Управляет состоянием ленты, модалок и сайдбара; * остальные компоненты получают данные через пропсы. */ export default function MainFeed() { const navigate = useNavigate(); const location = useLocation(); const { user, token, isAuthenticated } = useAuthContext(); const { deletePost } = useDeletePost(); const { toggleFavorite, loadFavorites } = useFavorites(); const { markAsFound } = usePostStatus(); // Открыт ли левый сайдбар (мобильный слайд-аут) const [sidebarOpen, setSidebarOpen] = useState(false); // Активный пункт навигации в сайдбаре const [activeNav, setActiveNav] = useState("Главная"); // Фильтр по типу объявления: null (все) | "loss" | "found" const [typeFilter, setTypeFilter] = useState(null); // Поисковый запрос (фильтр по title / description) const [searchQuery, setSearchQuery] = useState(""); // Открыта ли модалка создания нового объявления const [modalOpen, setModalOpen] = useState(false); // Объявление, которое сейчас редактируется (null — модалка закрыта) const [editAd, setEditAd] = useState(null); // Открыто ли модальное окно редактирования профиля const [profileModalOpen, setProfileModalOpen] = useState(false); // Открыто ли модальное окно аутентификации (login / register) const [authModalOpen, setAuthModalOpen] = useState(false); // Открыто ли модальное окно правил площадки const [rulesModalOpen, setRulesModalOpen] = useState(false); // Просмотр изображений: null — закрыто, { images, index } — открыто const [viewerData, setViewerData] = useState(null); // Массив объявлений из API (основная лента) const [ads, setAds] = useState([]); // Объявления текущего пользователя (загружаются отдельно по userId) const [myAds, setMyAds] = useState([]); // Set id объявлений, которые пользователь лайкнул (только локально) const [liked, setLiked] = useState(new Set()); // Состояния загрузки и ошибки для ленты const [loading, setLoading] = useState(true); const [error, setError] = useState(""); // Пагинация ленты const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const PAGE_SIZE = 20; // Данные профиля — из контекста авторизации (или пустой объект для гостя) const profileData = useMemo(() => { if (!user) return {}; // Если бэк присылает имя одним полем "name" — разбиваем на firstName/lastName // Если бэк присылает отдельные поля firstName/lastName — используем их напрямую const nameParts = (user.name || "").trim().split(/\s+/); const firstName = user.firstName || nameParts[0] || ""; const lastName = user.lastName || nameParts.slice(1).join(" ") || ""; // Аватар может быть относительным путём (/uploads/avatars/...) — добавляем API_URL const rawAvatar = user.avatar || null; const avatar = rawAvatar && rawAvatar.startsWith("/") ? `${API_URL}${rawAvatar}` : rawAvatar; return { login: user.login || "", firstName, lastName, email: user.email || "", phone: user.phone || "", avatar, id: user.id, }; }, [user]); /** Загружает ленту объявлений с бэкенда. */ const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => { if (pageNum === 0) { setLoading(true); } else { setLoadingMore(true); } setError(""); try { const params = new URLSearchParams({ page: pageNum, size: PAGE_SIZE, }); if (search) params.set("search", search); const res = await fetch(`${API_URL}/api/v1/posts?${params}`); if (!res.ok) throw new Error(`Ошибка ${res.status}`); const data = await res.json(); const content = data.content ?? []; const normalizedAds = content.map(ad => ({ ...ad, type: ad.type ? ad.type.toLowerCase() : "loss" })); setAds(prev => append ? [...prev, ...normalizedAds] : normalizedAds); setHasMore(!data.last); // data.last — стандартное поле Spring Page setPage(pageNum); } catch (err) { setError(err.message || "Не удалось загрузить объявления"); } finally { setLoading(false); setLoadingMore(false); } }, []); // Загружаем лайки пользователя при авторизации useEffect(() => { if (isAuthenticated && token) { loadFavorites(token).then(favoriteIds => { setLiked(new Set(favoriteIds)); }); } else { setLiked(new Set()); } }, [isAuthenticated, token, loadFavorites]); // Открываем модалку аутентификации если пришли с лендинга useEffect(() => { if (location.state?.openAuth) { setAuthModalOpen(true); window.history.replaceState({}, document.title); } }, [location.state]); // Первичная загрузка при монтировании компонента useEffect(() => { const timer = setTimeout(() => { loadFeed(0, searchQuery, false); }, 400); return () => clearTimeout(timer); }, [searchQuery, loadFeed]); /** Загружает объявления текущего пользователя по userId с бэкенда. */ const loadMyPosts = useCallback(async () => { if (!user?.id) return; setLoading(true); setError(""); try { const res = await fetch(`${API_URL}/api/v1/posts/${user.id}`); if (res.status === 404) { // У пользователя пока нет объявлений — это нормальная ситуация setMyAds([]); return; } if (!res.ok) throw new Error(`Ошибка ${res.status}`); const data = await res.json(); const posts = Array.isArray(data) ? data : (data.items ?? []); const normalizedPosts = posts.map(ad => ({ ...ad, type: ad.type ? ad.type.toLowerCase() : "loss" })); setMyAds(normalizedPosts); } catch (err) { setError(err.message || "Не удалось загрузить ваши объявления"); } finally { setLoading(false); } }, [user?.id]); /** * Переключает лайк на объявлении через API. */ const toggleLike = useCallback(async (id) => { if (!isAuthenticated || !token) { setAuthModalOpen(true); return; } const success = await toggleFavorite(id, token); if (success) { // Перезагружаем ленту для получения обновлённых данных loadFeed(); if (activeNav === "Мои объявления") { loadMyPosts(); } } }, [isAuthenticated, token, toggleFavorite, loadFeed, loadMyPosts, activeNav]); /** Удаляет объявление и перезагружает ленту */ const handleDelete = useCallback(async (postId) => { if (!window.confirm("Вы уверены, что хотите удалить это объявление?")) { return; } const success = await deletePost(postId); if (success) { loadFeed(); loadMyPosts(); } }, [deletePost, loadFeed, loadMyPosts]); /** Отмечает объявление как "Вещь найдена" и перезагружает ленту */ const handleMarkFound = useCallback(async (postId) => { if (!window.confirm("Отметить это объявление как \"Вещь найдена\"?")) { return; } const success = await markAsFound(postId, token); if (success) { loadFeed(); loadMyPosts(); } }, [markAsFound, token, loadFeed, loadMyPosts]); // После создания или редактирования объявления перезагружаем ленту, // чтобы получить актуальные данные (включая изображения) из БД const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]); const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]); const handleImageClick = useCallback((images, index, ad) => { if (images?.length) { setViewerData({ images, index, ad }); } }, []); /** * Обработчик нажатия «Разместить объявление». * Если пользователь не авторизован — открывает модалку входа. */ const handleCreatePost = useCallback(() => { if (!isAuthenticated) { setAuthModalOpen(true); } else { setModalOpen(true); } }, [isAuthenticated]); /** * Обработчик редактирования — разрешён только автору объявления. */ const handleEdit = useCallback((ad) => { if (!isAuthenticated) { setAuthModalOpen(true); } else { setEditAd(ad); } }, [isAuthenticated]); // Сентинел для бесконечного скролла — подгружает следующую страницу const observerRef = useRef(null); const sentinelRef = useCallback(node => { if (loadingMore) return; if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver(entries => { if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) { loadFeed(page + 1, searchQuery, true); } }, { rootMargin: "200px" }); if (node) observerRef.current.observe(node); }, [loadingMore, hasMore, loading, page, searchQuery, loadFeed]); // Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId useEffect(() => { if (activeNav === "Мои объявления" && isAuthenticated && user?.id) { loadMyPosts(); } }, [activeNav, isAuthenticated, user?.id, loadMyPosts]); // Фильтруем объявления в зависимости от выбранной вкладки и фильтра типа const filteredAds = (() => { let result; if (activeNav === "Мои объявления") { // Используем объявления, загруженные с бэкенда по userId result = myAds; } else if (activeNav === "Главная") { result = ads; } else { result = ads.filter(ad => activeNav === "Вещи" ? ["Вещь", "Другое"].includes(ad.category) : activeNav === "Питомцы" ? ad.category === "Питомец" : false ); } // Применяем поисковый фильтр по title / description if (typeFilter) { result = result.filter(ad => ad.type === typeFilter); } // Применяем фильтр по типу объявления if (typeFilter) { result = result.filter(ad => ad.type === typeFilter); } return result; })(); /** * Рендерит содержимое ленты в зависимости от текущего состояния: * — спиннер во время загрузки * — сообщение об ошибке * — заглушка при пустой ленте * — список карточек объявлений */ const renderContent = () => { if (loading) { return (
Загружаем объявления...
); } if (error) { return (
⚠️
Не удалось загрузить ленту
{error}
); } if (!filteredAds.length) { const emptyMsg = activeNav === "Мои объявления" ? { title: "У вас нет объявлений", sub: "Создайте первое — нажмите кнопку выше" } : { title: "Объявлений пока нет", sub: "Будьте первым — разместите объявление
о потере или находке" }; return (
🔍
{emptyMsg.title}
); } return filteredAds.map(ad => ( setAuthModalOpen(true)} /> )); }; return (
{/* Левый сайдбар с навигацией */} setSidebarOpen(false)} onNavClick={setActiveNav} isAuthenticated={isAuthenticated} onLoginClick={() => setAuthModalOpen(true)} onRulesClick={() => setRulesModalOpen(true)} onReviewsClick={() => navigate("/reviews")} />
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
{/* Иконка профиля в правом углу */} {isAuthenticated ? (
setProfileModalOpen(true)}>
) : ( )}
{/* Строка поиска */}
setSearchQuery(e.target.value)} /> {searchQuery && ( )}
{/* Фильтр по типу объявления */}
{/* Основная область: лента + правый сайдбар */}
{renderContent()} {activeNav === "Главная" && !loading && (
)} {loadingMore && (
)}
{/* Правый сайдбар: промо-блоки / фильтры */}
{/* Модалка создания нового объявления (только для авторизованных) */} {modalOpen && isAuthenticated && ( setModalOpen(false)} onSuccess={handleNewAd} /> )} {/* Модалка редактирования — открывается когда editAd !== null */} {editAd && isAuthenticated && ( setEditAd(null)} onSuccess={handleEditSave} /> )} {/* Модальное окно редактирования профиля */} {profileModalOpen && isAuthenticated && ( setProfileModalOpen(false)} initialData={profileData} /> )} {/* Модальное окно входа / регистрации */} {authModalOpen && ( setAuthModalOpen(false)} /> )} {/* Модальное окно правил площадки */} {rulesModalOpen && ( setRulesModalOpen(false)} /> )} {/* Просмотр изображений */} {viewerData && ( setViewerData(null)} postData={viewerData.ad} /> )}
); }