added filter on main feed

This commit is contained in:
SlimusMinus
2026-07-06 00:43:03 +03:00
parent 522633daec
commit b6aca224d7
8 changed files with 267 additions and 47 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import "../styles/index.css";
import { API_URL } from "../config/authConstants";
@@ -35,6 +35,8 @@ export default function MainFeed() {
const [activeNav, setActiveNav] = useState("Главная");
// Фильтр по типу объявления: null (все) | "loss" | "found"
const [typeFilter, setTypeFilter] = useState(null);
// Поисковый запрос (фильтр по title / description)
const [searchQuery, setSearchQuery] = useState("");
// Открыта ли модалка создания нового объявления
const [modalOpen, setModalOpen] = useState(false);
// Объявление, которое сейчас редактируется (null — модалка закрыта)
@@ -56,6 +58,12 @@ export default function MainFeed() {
// Состояния загрузки и ошибки для ленты
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(() => {
@@ -85,25 +93,38 @@ export default function MainFeed() {
}, [user]);
/** Загружает ленту объявлений с бэкенда. */
const loadFeed = useCallback(async () => {
setLoading(true);
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
if (pageNum === 0) {
setLoading(true);
} else {
setLoadingMore(true);
}
setError("");
try {
const res = await fetch(`${API_URL}/api/v1/posts`);
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();
// API может вернуть массив напрямую или объект { items: [] }
const ads = Array.isArray(data) ? data : (data.items ?? []);
// Нормализуем type: enum LOSS/FOUND → "loss"/"found"
const normalizedAds = ads.map(ad => ({
const content = data.content ?? [];
const normalizedAds = content.map(ad => ({
...ad,
type: ad.type ? ad.type.toLowerCase() : "loss"
}));
setAds(normalizedAds);
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);
}
}, []);
@@ -119,7 +140,13 @@ export default function MainFeed() {
}, [isAuthenticated, token, loadFavorites]);
// Первичная загрузка при монтировании компонента
useEffect(() => { loadFeed(); }, [loadFeed]);
useEffect(() => {
const timer = setTimeout(() => {
loadFeed(0, searchQuery, false);
}, 400);
return () => clearTimeout(timer);
}, [searchQuery, loadFeed]);
/** Загружает объявления текущего пользователя по userId с бэкенда. */
const loadMyPosts = useCallback(async () => {
@@ -153,6 +180,7 @@ export default function MainFeed() {
*/
const toggleLike = useCallback(async (id) => {
if (!isAuthenticated || !token) {
setAuthModalOpen(true);
return;
}
@@ -196,9 +224,9 @@ export default function MainFeed() {
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
const handleImageClick = useCallback((images, index) => {
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) {
setViewerData({ images, index });
setViewerData({ images, index, ad });
}
}, []);
@@ -225,6 +253,22 @@ export default function MainFeed() {
}
}, [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) {
@@ -251,6 +295,11 @@ export default function MainFeed() {
);
}
// Применяем поисковый фильтр по title / description
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
// Применяем фильтр по типу объявления
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
@@ -311,6 +360,7 @@ export default function MainFeed() {
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
isOwner={activeNav === "Мои объявления"}
onImageClick={handleImageClick}
onAuthRequired={() => setAuthModalOpen(true)}
/>
));
};
@@ -332,43 +382,82 @@ export default function MainFeed() {
<div className="f-main">
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
<div className="f-topbar">
<button className="f-menu-btn" onClick={() => setSidebarOpen(v => !v)}>
<MenuIcon />
<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>
<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>
<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>
<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 === null ? " active" : ""}`}
className={`f-filter-btn${typeFilter === null ? " active"
: ""}`}
onClick={() => setTypeFilter(null)}
>
Все объявления
</button>
<button
className={`f-filter-btn${typeFilter === "loss" ? " active" : ""}`}
className={`f-filter-btn${typeFilter === "loss" ? " active"
: ""}`}
onClick={() => setTypeFilter("loss")}
>
🔴 Потери
</button>
<button
className={`f-filter-btn${typeFilter === "found" ? " active" : ""}`}
className={`f-filter-btn${typeFilter === "found" ? " active"
: ""}`}
onClick={() => setTypeFilter("found")}
>
🟢 Находки
@@ -379,9 +468,17 @@ export default function MainFeed() {
<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 />
<RightSidebar/>
</div>
</div>
@@ -428,6 +525,7 @@ export default function MainFeed() {
images={viewerData.images}
initialIndex={viewerData.index}
onClose={() => setViewerData(null)}
postData={viewerData.ad}
/>
)}
</div>