Merge pull request #28 from SlimusMinus/add_filter
added filter on main feed
This commit is contained in:
@@ -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 CloseIcon from "./icons/CloseIcon";
|
||||||
import { API_URL } from "../config/authConstants";
|
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 [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}`));
|
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);
|
setIndex(initialIndex);
|
||||||
}, [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;
|
if (!urls.length) return null;
|
||||||
|
|
||||||
return (
|
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}>
|
<button className="f-viewer-close" onClick={onClose}>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="f-viewer">
|
<div className="f-viewer">
|
||||||
<img
|
<div className="f-viewer-img-wrap" onClick={handleImgClick}>
|
||||||
src={urls[index]}
|
<img
|
||||||
alt=""
|
src={urls[index]}
|
||||||
className="f-viewer-img"
|
alt=""
|
||||||
onClick={handleImgClick}
|
className="f-viewer-img"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showContact && (
|
||||||
|
<button className="f-viewer-contact" onClick={handleContact}>
|
||||||
|
Написать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{urls.length > 1 && (
|
{urls.length > 1 && (
|
||||||
<div className="f-viewer-dots">
|
<div className="f-viewer-dots">
|
||||||
|
|||||||
@@ -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 { useNavigate } from "react-router-dom";
|
||||||
import "../styles/index.css";
|
import "../styles/index.css";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
@@ -35,6 +35,8 @@ export default function MainFeed() {
|
|||||||
const [activeNav, setActiveNav] = useState("Главная");
|
const [activeNav, setActiveNav] = useState("Главная");
|
||||||
// Фильтр по типу объявления: null (все) | "loss" | "found"
|
// Фильтр по типу объявления: null (все) | "loss" | "found"
|
||||||
const [typeFilter, setTypeFilter] = useState(null);
|
const [typeFilter, setTypeFilter] = useState(null);
|
||||||
|
// Поисковый запрос (фильтр по title / description)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
// Открыта ли модалка создания нового объявления
|
// Открыта ли модалка создания нового объявления
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
// Объявление, которое сейчас редактируется (null — модалка закрыта)
|
// Объявление, которое сейчас редактируется (null — модалка закрыта)
|
||||||
@@ -56,6 +58,12 @@ export default function MainFeed() {
|
|||||||
// Состояния загрузки и ошибки для ленты
|
// Состояния загрузки и ошибки для ленты
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
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(() => {
|
const profileData = useMemo(() => {
|
||||||
@@ -85,25 +93,38 @@ export default function MainFeed() {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
/** Загружает ленту объявлений с бэкенда. */
|
/** Загружает ленту объявлений с бэкенда. */
|
||||||
const loadFeed = useCallback(async () => {
|
const loadFeed = useCallback(async (pageNum = 0, search = "", append = false) => {
|
||||||
setLoading(true);
|
if (pageNum === 0) {
|
||||||
|
setLoading(true);
|
||||||
|
} else {
|
||||||
|
setLoadingMore(true);
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
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}`);
|
if (!res.ok) throw new Error(`Ошибка ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// API может вернуть массив напрямую или объект { items: [] }
|
|
||||||
const ads = Array.isArray(data) ? data : (data.items ?? []);
|
const content = data.content ?? [];
|
||||||
// Нормализуем type: enum LOSS/FOUND → "loss"/"found"
|
const normalizedAds = content.map(ad => ({
|
||||||
const normalizedAds = ads.map(ad => ({
|
|
||||||
...ad,
|
...ad,
|
||||||
type: ad.type ? ad.type.toLowerCase() : "loss"
|
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) {
|
} catch (err) {
|
||||||
setError(err.message || "Не удалось загрузить объявления");
|
setError(err.message || "Не удалось загрузить объявления");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -119,7 +140,13 @@ export default function MainFeed() {
|
|||||||
}, [isAuthenticated, token, loadFavorites]);
|
}, [isAuthenticated, token, loadFavorites]);
|
||||||
|
|
||||||
// Первичная загрузка при монтировании компонента
|
// Первичная загрузка при монтировании компонента
|
||||||
useEffect(() => { loadFeed(); }, [loadFeed]);
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
loadFeed(0, searchQuery, false);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, loadFeed]);
|
||||||
|
|
||||||
|
|
||||||
/** Загружает объявления текущего пользователя по userId с бэкенда. */
|
/** Загружает объявления текущего пользователя по userId с бэкенда. */
|
||||||
const loadMyPosts = useCallback(async () => {
|
const loadMyPosts = useCallback(async () => {
|
||||||
@@ -153,6 +180,7 @@ export default function MainFeed() {
|
|||||||
*/
|
*/
|
||||||
const toggleLike = useCallback(async (id) => {
|
const toggleLike = useCallback(async (id) => {
|
||||||
if (!isAuthenticated || !token) {
|
if (!isAuthenticated || !token) {
|
||||||
|
setAuthModalOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +224,9 @@ export default function MainFeed() {
|
|||||||
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
||||||
const handleEditSave = 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) {
|
if (images?.length) {
|
||||||
setViewerData({ images, index });
|
setViewerData({ images, index, ad });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -225,6 +253,22 @@ export default function MainFeed() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [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
|
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeNav === "Мои объявления" && isAuthenticated && user?.id) {
|
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) {
|
if (typeFilter) {
|
||||||
result = result.filter(ad => ad.type === typeFilter);
|
result = result.filter(ad => ad.type === typeFilter);
|
||||||
@@ -311,6 +360,7 @@ export default function MainFeed() {
|
|||||||
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
|
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
|
||||||
isOwner={activeNav === "Мои объявления"}
|
isOwner={activeNav === "Мои объявления"}
|
||||||
onImageClick={handleImageClick}
|
onImageClick={handleImageClick}
|
||||||
|
onAuthRequired={() => setAuthModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
@@ -332,43 +382,82 @@ export default function MainFeed() {
|
|||||||
<div className="f-main">
|
<div className="f-main">
|
||||||
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
|
{/* Верхняя панель: бургер-меню + кнопка создания объявления */}
|
||||||
<div className="f-topbar">
|
<div className="f-topbar">
|
||||||
<button className="f-menu-btn" onClick={() => setSidebarOpen(v => !v)}>
|
<button className="f-menu-btn"
|
||||||
<MenuIcon />
|
onClick={() => setSidebarOpen(v => !v)}>
|
||||||
|
<MenuIcon/>
|
||||||
</button>
|
</button>
|
||||||
<div className="f-topbar-center">
|
<div className="f-topbar-center">
|
||||||
<button className="f-post-btn" onClick={handleCreatePost}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Иконка профиля в правом углу */}
|
{/* Иконка профиля в правом углу */}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<div className="f-topbar-profile" onClick={() => setProfileModalOpen(true)}>
|
<div className="f-topbar-profile"
|
||||||
<ProfileAvatar avatar={profileData.avatar} name={`${profileData.firstName || ""} ${profileData.lastName || ""}`} />
|
onClick={() => setProfileModalOpen(true)}>
|
||||||
</div>
|
<ProfileAvatar avatar={profileData.avatar}
|
||||||
|
name={`${profileData.firstName
|
||||||
|
|| ""} ${profileData.lastName || ""}`}/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button className="f-login-btn" onClick={() => setAuthModalOpen(true)}>
|
<button className="f-login-btn"
|
||||||
Войти
|
onClick={() => setAuthModalOpen(true)}>
|
||||||
</button>
|
Войти
|
||||||
|
</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>
|
||||||
|
|
||||||
{/* Фильтр по типу объявления */}
|
{/* Фильтр по типу объявления */}
|
||||||
<div className="f-filter-bar">
|
<div className="f-filter-bar">
|
||||||
<button
|
<button
|
||||||
className={`f-filter-btn${typeFilter === null ? " active" : ""}`}
|
className={`f-filter-btn${typeFilter === null ? " active"
|
||||||
|
: ""}`}
|
||||||
onClick={() => setTypeFilter(null)}
|
onClick={() => setTypeFilter(null)}
|
||||||
>
|
>
|
||||||
Все объявления
|
Все объявления
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`f-filter-btn${typeFilter === "loss" ? " active" : ""}`}
|
className={`f-filter-btn${typeFilter === "loss" ? " active"
|
||||||
|
: ""}`}
|
||||||
onClick={() => setTypeFilter("loss")}
|
onClick={() => setTypeFilter("loss")}
|
||||||
>
|
>
|
||||||
🔴 Потери
|
🔴 Потери
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`f-filter-btn${typeFilter === "found" ? " active" : ""}`}
|
className={`f-filter-btn${typeFilter === "found" ? " active"
|
||||||
|
: ""}`}
|
||||||
onClick={() => setTypeFilter("found")}
|
onClick={() => setTypeFilter("found")}
|
||||||
>
|
>
|
||||||
🟢 Находки
|
🟢 Находки
|
||||||
@@ -379,9 +468,17 @@ export default function MainFeed() {
|
|||||||
<div className="f-wrap">
|
<div className="f-wrap">
|
||||||
<div className="f-feed">
|
<div className="f-feed">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
{activeNav === "Главная" && !loading && (
|
||||||
|
<div ref={sentinelRef} style={{height: "1px"}}/>
|
||||||
|
)}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="f-center" style={{padding: "1rem"}}>
|
||||||
|
<div className="f-spinner"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Правый сайдбар: промо-блоки / фильтры */}
|
{/* Правый сайдбар: промо-блоки / фильтры */}
|
||||||
<RightSidebar />
|
<RightSidebar/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -428,6 +525,7 @@ export default function MainFeed() {
|
|||||||
images={viewerData.images}
|
images={viewerData.images}
|
||||||
initialIndex={viewerData.index}
|
initialIndex={viewerData.index}
|
||||||
onClose={() => setViewerData(null)}
|
onClose={() => setViewerData(null)}
|
||||||
|
postData={viewerData.ad}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ import { useChats } from "../hooks/useChats";
|
|||||||
import { useAuthContext } from "../context/AuthContext";
|
import { useAuthContext } from "../context/AuthContext";
|
||||||
import { API_URL } from "../config/authConstants";
|
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 navigate = useNavigate();
|
||||||
const { createOrGetChat } = useChats();
|
const { createOrGetChat } = useChats();
|
||||||
const { isAuthenticated } = useAuthContext();
|
const { isAuthenticated } = useAuthContext();
|
||||||
|
|
||||||
const handleContact = async (e) => {
|
const handleContact = async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
if (onAuthRequired) onAuthRequired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const chat = await createOrGetChat(ad.userId, ad.id, {
|
const chat = await createOrGetChat(ad.userId, ad.id, {
|
||||||
title: ad.title,
|
title: ad.title,
|
||||||
@@ -59,7 +63,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
|||||||
return (
|
return (
|
||||||
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
|
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
|
||||||
<div className="f-card-inner">
|
<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
|
{imageUrl
|
||||||
? <img src={imageUrl} alt={ad.title} />
|
? <img src={imageUrl} alt={ad.title} />
|
||||||
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
||||||
@@ -97,7 +101,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="f-card-foot-right">
|
<div className="f-card-foot-right">
|
||||||
{isAuthenticated && !isOwner && !isClosed && (
|
{!isOwner && !isClosed && (
|
||||||
<button className="f-card-contact" onClick={handleContact}>
|
<button className="f-card-contact" onClick={handleContact}>
|
||||||
Написать
|
Написать
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import headphonesIcon from '../assets/headphones.png';
|
|||||||
import watchIcon from '../assets/watch.png';
|
import watchIcon from '../assets/watch.png';
|
||||||
import glassesIcon from '../assets/glasses.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 = [
|
export const FLOAT_ICONS = [
|
||||||
{ icon: bagIcon, top: "5%", left: "5%", delay: "0s" },
|
{ icon: bagIcon, top: "5%", left: "5%", delay: "0s" },
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
position: relative; overflow: hidden; flex-shrink: 0;
|
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; }
|
.f-card-emoji { font-size: 3rem; opacity: .22; }
|
||||||
|
|
||||||
/* Badges */
|
/* Badges */
|
||||||
|
|||||||
@@ -52,6 +52,49 @@
|
|||||||
}
|
}
|
||||||
.f-topbar-login-icon { font-size: 1.1rem; line-height: 1; }
|
.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 ── */
|
/* ── Filter Bar ── */
|
||||||
.f-filter-bar {
|
.f-filter-bar {
|
||||||
display: flex; gap: 10px;
|
display: flex; gap: 10px;
|
||||||
|
|||||||
@@ -137,6 +137,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.f-viewer-img-wrap {
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
.f-viewer-img {
|
.f-viewer-img {
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -144,6 +147,23 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 8px 40px rgba(0,0,0,.5);
|
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 {
|
.f-viewer-close {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
.rv-page-title {
|
.rv-page-title {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
.rv-star {
|
.rv-star {
|
||||||
color: rgba(255, 255, 255, .2);
|
color: var(--muted);
|
||||||
transition: color .15s, transform .15s;
|
transition: color .15s, transform .15s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.rv-form-rating {
|
.rv-form-rating {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -147,11 +147,11 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.rv-form-textarea {
|
.rv-form-textarea {
|
||||||
background: rgba(255, 255, 255, .05);
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: .88rem;
|
font-size: .88rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(45, 224, 200, .12);
|
box-shadow: 0 0 0 3px rgba(45, 224, 200, .12);
|
||||||
}
|
}
|
||||||
.rv-form-textarea::placeholder {
|
.rv-form-textarea::placeholder {
|
||||||
color: rgba(255, 255, 255, .32);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.rv-form-footer {
|
.rv-form-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
.rv-card-name {
|
.rv-card-name {
|
||||||
font-size: .88rem;
|
font-size: .88rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -307,7 +307,7 @@
|
|||||||
.rv-card-text {
|
.rv-card-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: .88rem;
|
font-size: .88rem;
|
||||||
color: rgba(255, 255, 255, .78);
|
color: var(--text-primary);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user