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 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">

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 { 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>

View File

@@ -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>

View File

@@ -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" },

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
} }