Files
nahodka-front/src/pages/MainFeed.jsx
2026-07-07 00:00:21 +03:00

543 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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 (
<div className="f-center">
<div className="f-spinner-wrap">
<div className="f-spinner" />
Загружаем объявления...
</div>
</div>
);
}
if (error) {
return (
<div className="f-center">
<div className="f-center-icon">⚠️</div>
<div className="f-center-title">Не удалось загрузить ленту</div>
<div className="f-center-sub">{error}</div>
</div>
);
}
if (!filteredAds.length) {
const emptyMsg = activeNav === "Мои объявления"
? { title: "У вас нет объявлений", sub: "Создайте первое — нажмите кнопку выше" }
: { title: "Объявлений пока нет", sub: "Будьте первым — разместите объявление<br />о потере или находке" };
return (
<div className="f-center">
<div className="f-center-icon">🔍</div>
<div className="f-center-title">{emptyMsg.title}</div>
<div className="f-center-sub"
dangerouslySetInnerHTML={{ __html: emptyMsg.sub }}
/>
</div>
);
}
return filteredAds.map(ad => (
<PostOnFeed
key={ad.id}
ad={ad}
liked={liked.has(ad.id)}
onLike={toggleLike}
onEdit={activeNav === "Мои объявления" ? handleEdit : undefined}
onDelete={activeNav === "Мои объявления" ? handleDelete : undefined}
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
isOwner={activeNav === "Мои объявления"}
onImageClick={handleImageClick}
onAuthRequired={() => setAuthModalOpen(true)}
/>
));
};
return (
<div className="f-app">
{/* Левый сайдбар с навигацией */}
<Sidebar
open={sidebarOpen}
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
onNavClick={setActiveNav}
isAuthenticated={isAuthenticated}
onLoginClick={() => setAuthModalOpen(true)}
onRulesClick={() => setRulesModalOpen(true)}
onReviewsClick={() => navigate("/reviews")}
/>
<div className="f-main">
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
<div className="f-topbar">
<button className="f-menu-btn"
onClick={() => setSidebarOpen(v => !v)}>
<MenuIcon/>
</button>
<div className="f-topbar-center">
<button className="f-post-btn" onClick={handleCreatePost}>
<span style={{
fontSize: "1.15rem",
fontWeight: 900,
lineHeight: 1
}}>+</span>
Разместить объявление
</button>
</div>
{/* Иконка профиля в правом углу */}
{isAuthenticated ? (
<div className="f-topbar-profile"
onClick={() => setProfileModalOpen(true)}>
<ProfileAvatar avatar={profileData.avatar}
name={`${profileData.firstName
|| ""} ${profileData.lastName || ""}`}/>
</div>
) : (
<button className="f-login-btn"
onClick={() => setAuthModalOpen(true)}>
Войти
</button>
)}
</div>
{/* Строка поиска */}
<div className="f-search-bar">
<svg className="f-search-icon" 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>
<input
className="f-search-input"
type="text"
placeholder="Поиск по названию или описанию..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button className="f-search-clear"
onClick={() => setSearchQuery("")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" width="18" height="18">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
)}
</div>
{/* Фильтр по типу объявления */}
<div className="f-filter-bar">
<button
className={`f-filter-btn${typeFilter === "loss" ? " active"
: ""}`}
onClick={() => setTypeFilter("loss")}
>
🔴 Потери
</button>
<button
className={`f-filter-btn${typeFilter === null ? " active"
: ""}`}
onClick={() => setTypeFilter(null)}
>
Все объявления
</button>
<button
className={`f-filter-btn${typeFilter === "found" ? " active"
: ""}`}
onClick={() => setTypeFilter("found")}
>
🟢 Находки
</button>
</div>
{/* Основная область: лента + правый сайдбар */}
<div className="f-wrap">
<div className="f-feed">
{renderContent()}
{activeNav === "Главная" && !loading && (
<div ref={sentinelRef} style={{height: "1px"}}/>
)}
{loadingMore && (
<div className="f-center" style={{padding: "1rem"}}>
<div className="f-spinner"/>
</div>
)}
</div>
{/* Правый сайдбар: промо-блоки / фильтры */}
<RightSidebar/>
</div>
</div>
{/* Модалка создания нового объявления (только для авторизованных) */}
{modalOpen && isAuthenticated && (
<CreateUpdatePost
onClose={() => setModalOpen(false)}
onSuccess={handleNewAd}
/>
)}
{/* Модалка редактирования — открывается когда editAd !== null */}
{editAd && isAuthenticated && (
<CreateUpdatePost
initialData={editAd}
onClose={() => setEditAd(null)}
onSuccess={handleEditSave}
/>
)}
{/* Модальное окно редактирования профиля */}
{profileModalOpen && isAuthenticated && (
<ProfileModal
onClose={() => setProfileModalOpen(false)}
initialData={profileData}
/>
)}
{/* Модальное окно входа / регистрации */}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
/>
)}
{/* Модальное окно правил площадки */}
{rulesModalOpen && (
<RulesModal onClose={() => setRulesModalOpen(false)} />
)}
{/* Просмотр изображений */}
{viewerData && (
<ImageViewer
images={viewerData.images}
initialIndex={viewerData.index}
onClose={() => setViewerData(null)}
postData={viewerData.ad}
/>
)}
</div>
);
}