diff --git a/src/components/ImageViewer.jsx b/src/components/ImageViewer.jsx index f7af9e7..5db113e 100644 --- a/src/components/ImageViewer.jsx +++ b/src/components/ImageViewer.jsx @@ -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 ( -
e.target === e.currentTarget && onClose()}> +
e.target === e.currentTarget && !swiped.current && onClose()} + >
- +
+ +
+ + {showContact && ( + + )} {urls.length > 1 && (
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() {
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
-
{/* Иконка профиля в правом углу */} {isAuthenticated ? ( -
setProfileModalOpen(true)}> - -
+
setProfileModalOpen(true)}> + +
) : ( - + + )} +
+ + {/* Строка поиска */} +
+ + + + + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + )}
{/* Фильтр по типу объявления */}
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; }