diff --git a/src/components/MainFeed.jsx b/src/components/MainFeed.jsx
index 2e1a2e7..3413ee8 100644
--- a/src/components/MainFeed.jsx
+++ b/src/components/MainFeed.jsx
@@ -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() {
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
-
+
+ {/* Строка поиска */}
+
+
+
setSearchQuery(e.target.value)}
+ />
+ {searchQuery && (
+
setSearchQuery("")}>
+
+
)}
{/* Фильтр по типу объявления */}
setTypeFilter(null)}
>
Все объявления
setTypeFilter("loss")}
>
🔴 Потери
setTypeFilter("found")}
>
🟢 Находки
@@ -379,9 +468,17 @@ export default function MainFeed() {
{renderContent()}
+ {activeNav === "Главная" && !loading && (
+
+ )}
+ {loadingMore && (
+
+ )}
{/* Правый сайдбар: промо-блоки / фильтры */}
-
+
@@ -428,6 +525,7 @@ export default function MainFeed() {
images={viewerData.images}
initialIndex={viewerData.index}
onClose={() => setViewerData(null)}
+ postData={viewerData.ad}
/>
)}
diff --git a/src/components/PostOnFeed.jsx b/src/components/PostOnFeed.jsx
index 97e8ef3..9f08b99 100644
--- a/src/components/PostOnFeed.jsx
+++ b/src/components/PostOnFeed.jsx
@@ -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 (
-
{ e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0); }}>
+
{ e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0, ad); }}>
{imageUrl
?

:
{ad.emoji || "📋"}
@@ -97,7 +101,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
)}
- {isAuthenticated && !isOwner && !isClosed && (
+ {!isOwner && !isClosed && (
Написать
diff --git a/src/config/authConstants.js b/src/config/authConstants.js
index df8b724..68fee2b 100644
--- a/src/config/authConstants.js
+++ b/src/config/authConstants.js
@@ -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" },
diff --git a/src/styles/card.css b/src/styles/card.css
index d46ca17..6628d98 100644
--- a/src/styles/card.css
+++ b/src/styles/card.css
@@ -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 */
diff --git a/src/styles/layout.css b/src/styles/layout.css
index 10d2176..1c58cae 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -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;
diff --git a/src/styles/modal.css b/src/styles/modal.css
index 97c7b7f..939c0c6 100644
--- a/src/styles/modal.css
+++ b/src/styles/modal.css
@@ -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;
diff --git a/src/styles/reviews.css b/src/styles/reviews.css
index 69bf325..6cb896b 100644
--- a/src/styles/reviews.css
+++ b/src/styles/reviews.css
@@ -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;
}