543 lines
21 KiB
JavaScript
543 lines
21 KiB
JavaScript
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>
|
||
);
|
||
}
|