added filter on main feed
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user