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,9 +1,17 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import CloseIcon from "./icons/CloseIcon";
import { API_URL } from "../config/authConstants";
import { useChats } from "../hooks/useChats";
import { useAuthContext } from "../context/AuthContext";
export default function ImageViewer({ images, initialIndex = 0, onClose }) {
export default function ImageViewer({ images, initialIndex = 0, onClose, postData }) {
const [index, setIndex] = useState(initialIndex);
const touchStart = useRef(null);
const swiped = useRef(false);
const navigate = useNavigate();
const { createOrGetChat } = useChats();
const { isAuthenticated } = useAuthContext();
const urls = images.map(u => (u.startsWith("http") ? u : `${API_URL}${u}`));
@@ -33,21 +41,68 @@ export default function ImageViewer({ images, initialIndex = 0, onClose }) {
setIndex(initialIndex);
}, [initialIndex]);
const handleTouchStart = useCallback((e) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, []);
const handleTouchEnd = useCallback((e) => {
if (!touchStart.current) return;
const dx = e.changedTouches[0].clientX - touchStart.current.x;
const dy = e.changedTouches[0].clientY - touchStart.current.y;
const isHorizontal = Math.abs(dx) > Math.abs(dy);
if (isHorizontal && Math.abs(dx) > 50) {
swiped.current = true;
if (dx > 0) prev();
else next();
setTimeout(() => { swiped.current = false; }, 400);
}
touchStart.current = null;
}, [prev, next]);
const handleContact = useCallback(async () => {
if (!postData) return;
try {
const chat = await createOrGetChat(postData.userId, postData.id, {
title: postData.title,
images: postData.images,
emoji: postData.emoji,
});
onClose();
navigate(`/chats/${chat.id}`);
} catch (err) {
console.error(err);
}
}, [postData, createOrGetChat, navigate, onClose]);
const showContact = isAuthenticated && postData && !postData.isOwner && postData.status !== 'CLOSED';
if (!urls.length) return null;
return (
<div className="f-viewer-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div
className="f-viewer-bg"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={e => e.target === e.currentTarget && !swiped.current && onClose()}
>
<button className="f-viewer-close" onClick={onClose}>
<CloseIcon />
</button>
<div className="f-viewer">
<img
src={urls[index]}
alt=""
className="f-viewer-img"
onClick={handleImgClick}
/>
<div className="f-viewer-img-wrap" onClick={handleImgClick}>
<img
src={urls[index]}
alt=""
className="f-viewer-img"
/>
</div>
{showContact && (
<button className="f-viewer-contact" onClick={handleContact}>
Написать
</button>
)}
{urls.length > 1 && (
<div className="f-viewer-dots">

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>

View File

@@ -9,13 +9,17 @@ import { useChats } from "../hooks/useChats";
import { useAuthContext } from "../context/AuthContext";
import { API_URL } from "../config/authConstants";
export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick }) {
export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onAuthRequired }) {
const navigate = useNavigate();
const { createOrGetChat } = useChats();
const { isAuthenticated } = useAuthContext();
const handleContact = async (e) => {
e.stopPropagation();
if (!isAuthenticated) {
if (onAuthRequired) onAuthRequired();
return;
}
try {
const chat = await createOrGetChat(ad.userId, ad.id, {
title: ad.title,
@@ -59,7 +63,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
return (
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
<div className="f-card-inner">
<div className="f-card-img" onClick={e => { e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0); }}>
<div className="f-card-img" onClick={e => { e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0, ad); }}>
{imageUrl
? <img src={imageUrl} alt={ad.title} />
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
@@ -97,7 +101,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
)}
</div>
<div className="f-card-foot-right">
{isAuthenticated && !isOwner && !isClosed && (
{!isOwner && !isClosed && (
<button className="f-card-contact" onClick={handleContact}>
Написать
</button>

View File

@@ -9,7 +9,7 @@ import headphonesIcon from '../assets/headphones.png';
import watchIcon from '../assets/watch.png';
import glassesIcon from '../assets/glasses.png';
export const API_URL = process.env.REACT_APP_API_URL || "http://localhost:8080";
export const API_URL = process.env.REACT_APP_API_URL || "http://192.168.1.68:8080";
export const FLOAT_ICONS = [
{ icon: bagIcon, top: "5%", left: "5%", delay: "0s" },

View File

@@ -19,7 +19,7 @@
display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden; flex-shrink: 0;
}
.f-card-img img { width: 100%; height: 100%; object-fit: cover; object-position: center; display: block; position: absolute; inset: 0; }
.f-card-img img { width: 100%; height: 100%; object-fit: contain; object-position: center; display: block; position: absolute; inset: 0; }
.f-card-emoji { font-size: 3rem; opacity: .22; }
/* Badges */

View File

@@ -52,6 +52,49 @@
}
.f-topbar-login-icon { font-size: 1.1rem; line-height: 1; }
/* ── Search Bar ── */
.f-search-bar {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px;
background: var(--bg-filter); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.f-search-icon {
width: 18px; height: 18px; flex-shrink: 0;
color: var(--muted);
}
.f-search-input {
flex: 1; min-width: 0;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
font-family: var(--font); font-size: 0.9rem;
color: var(--text-primary);
outline: none;
transition: border-color .2s, box-shadow .2s;
}
.f-search-input::placeholder { color: var(--muted); }
.f-search-input:focus {
border-color: rgba(var(--teal-rgb), .5);
box-shadow: 0 0 0 3px rgba(var(--teal-rgb), .12);
}
.f-search-clear {
background: none; border: none; cursor: pointer;
color: var(--muted); padding: 4px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: color .15s, background .15s;
}
.f-search-clear:hover { color: var(--text-primary); background: var(--hover-bg); }
@media (max-width: 768px) {
.f-search-bar { padding: 8px 12px; }
.f-search-input { padding: 8px 12px; font-size: 0.85rem; }
}
/* ── Filter Bar ── */
.f-filter-bar {
display: flex; gap: 10px;

View File

@@ -137,6 +137,9 @@
align-items: center;
user-select: none;
}
.f-viewer-img-wrap {
touch-action: pan-y;
}
.f-viewer-img {
max-width: 90vw;
max-height: 80vh;
@@ -144,6 +147,23 @@
object-fit: contain;
cursor: pointer;
box-shadow: 0 8px 40px rgba(0,0,0,.5);
touch-action: pan-y;
}
.f-viewer-contact {
margin-top: 16px;
padding: 10px 28px;
border-radius: 12px;
border: none;
background: var(--teal);
color: #fff;
font-size: .95rem;
font-weight: 600;
cursor: pointer;
transition: opacity .2s;
z-index: 1;
}
.f-viewer-contact:hover {
opacity: .85;
}
.f-viewer-close {
position: fixed;

View File

@@ -2,7 +2,7 @@
.rv-page-title {
font-size: 1.15rem;
font-weight: 900;
color: #fff;
color: var(--text-primary);
margin: 0;
}
@@ -101,7 +101,7 @@
gap: 3px;
}
.rv-star {
color: rgba(255, 255, 255, .2);
color: var(--muted);
transition: color .15s, transform .15s;
flex-shrink: 0;
}
@@ -129,7 +129,7 @@
margin: 0;
font-size: 1rem;
font-weight: 800;
color: #fff;
color: var(--text-primary);
}
.rv-form-rating {
display: flex;
@@ -147,11 +147,11 @@
font-weight: 700;
}
.rv-form-textarea {
background: rgba(255, 255, 255, .05);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
color: #fff;
color: var(--text-primary);
font-family: var(--font);
font-size: .88rem;
resize: vertical;
@@ -163,7 +163,7 @@
box-shadow: 0 0 0 3px rgba(45, 224, 200, .12);
}
.rv-form-textarea::placeholder {
color: rgba(255, 255, 255, .32);
color: var(--muted);
}
.rv-form-footer {
display: flex;
@@ -283,7 +283,7 @@
.rv-card-name {
font-size: .88rem;
font-weight: 700;
color: #fff;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -307,7 +307,7 @@
.rv-card-text {
margin: 0;
font-size: .88rem;
color: rgba(255, 255, 255, .78);
color: var(--text-primary);
line-height: 1.55;
}