added blur on topBar

This commit is contained in:
SlimusMinus
2026-09-09 23:42:03 +03:00
parent 800cb8233a
commit 4021840429
6 changed files with 847 additions and 642 deletions

View File

@@ -1,120 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import MenuIcon from "../icons/MenuIcon";
import NahodkaLogo from "../NahodkaLogo";
/**
* TopBar ленты (Telegram-style):
* слева — круглая стеклянная кнопка-логотип (открывает фильтр типа),
* справа — squircle-кнопка-гамбургер (открывает SideBar).
*/
export default function FeedTopBar({ onMenuClick, typeFilter, onTypeFilterChange }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const menuRef = useRef(null);
const btnRef = useRef(null);
const options = [
{ key: "loss", label: t("feed.filterLoss") },
{ key: null, label: t("feed.filterAll") },
{ key: "found", label: t("feed.filterFound") },
];
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (
menuRef.current && !menuRef.current.contains(e.target) &&
btnRef.current && !btnRef.current.contains(e.target)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleSelect = (key) => {
onTypeFilterChange(key);
setOpen(false);
};
return (
<div className="f-topbar-dropdown-wrap">
<div className="f-topbar">
<button
type="button"
className="f-menu-btn"
onClick={onMenuClick}
aria-label={t("feed.openMenu")}
>
<MenuIcon />
</button>
<button
ref={btnRef}
type="button"
className="f-logo-btn"
onClick={() => setOpen((v) => !v)}
aria-label={t("feed.openFilter")}
aria-haspopup="menu"
aria-expanded={open}
>
<NahodkaLogo className="f-topbar-logo" />
<svg
className={`f-topbar-chevron${open ? " open" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<span className="f-topbar-spacer" />
</div>
<AnimatePresence>
{open && (
<motion.div
className="f-topbar-dropdown-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={() => setOpen(false)}
/>
)}
</AnimatePresence>
<AnimatePresence>
{open && (
<motion.div
ref={menuRef}
className="f-topbar-dropdown-menu"
initial={{ opacity: 0, scale: 0.95, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
{options.map((opt) => (
<button
key={opt.key ?? "all"}
type="button"
className={`f-topbar-dropdown-item${typeFilter === opt.key ? " active" : ""}`}
onClick={() => handleSelect(opt.key)}
>
{opt.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,484 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import "../styles/index.css";
import { getImageUrl } from "../config/authConstants";
import Sidebar from "../components/Sidebar";
import BottomNav from "../components/nav/BottomNav";
import RightSidebar from "../components/RightSidebar";
import CreateUpdatePost from "./CreateUpdatePost";
import ImageViewer from "../components/ImageViewer";
import CityFilter from "../components/CityFilter";
import { ProfileModal } from "../components/profile";
import AuthModal from "../components/AuthModal";
import RulesModal from "./RulesModal";
import FeedTopBar from "../components/feed/FeedTopBar";
import FeedSearchBar from "../components/feed/FeedSearchBar";
import FeedContent from "../components/feed/FeedContent";
import MapView from "../components/MapView";
import PostDetailModal from "../components/PostDetailModal";
import { useAuthContext } from "../context/AuthContext";
import { useCities } from "../hooks/useCities";
import { useFeedPosts } from "../hooks/useFeedPosts";
import { useMyPosts } from "../hooks/useMyPosts";
import { useCityFeed } from "../hooks/useCityFeed";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll";
import { useModals } from "../hooks/useModals";
import { useFeedActions } from "../hooks/useFeedActions";
import { useFavorites } from "../hooks/useFavorites";
import { useTranslation } from 'react-i18next';
import { useProfile } from "../hooks/useProfile";
/**
* MainFeed — корневой компонент приложения.
* Композиция хуков состояния + презентационных компонентов ленты.
*/
export default function MainFeed() {
const navigate = useNavigate();
const location = useLocation();
const { user, token, isAuthenticated, updateUser } = useAuthContext();
const { fetchProfile } = useProfile();
const { cities } = useCities();
const { loadFavorites, loadFavoritePosts, error: favoritesError } = useFavorites();
const { t } = useTranslation();
const modals = useModals();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeNav, setActiveNav] = useState("home");
const [typeFilter, setTypeFilter] = useState(null);
const [selectedCities, setSelectedCities] = useState([]);
const [searchQuery, setSearchQuery] = useState("");
const [liked, setLiked] = useState(new Set());
const [likeCountAdj, setLikeCountAdj] = useState({});
const [favoritePosts, setFavoritePosts] = useState([]);
const profileFetchedFor = useRef(null);
const [favoritesLoading, setFavoritesLoading] = useState(false);
const feedRef = useRef(null);
// Состояние ленты перед переходом на карту — чтобы вернуть юзера на то же место
const feedStateRef = useRef({ scrollTop: 0, url: "/feed", activeNav: "home" });
const restoreScrollRef = useRef(false);
const isSearchView = new URLSearchParams(location.search).get("view") === "search";
const isMapView = new URLSearchParams(location.search).get("view") === "map";
// параметры фокуса карты на конкретном объявлении (?view=map&post=ID&lat=..&lng=..)
const mapFocusParams = new URLSearchParams(location.search);
const focusPostId = isMapView ? mapFocusParams.get("post") : null;
const focusLat = parseFloat(mapFocusParams.get("lat"));
const focusLng = parseFloat(mapFocusParams.get("lng"));
const hasActiveFilters = searchQuery.trim().length > 0 || selectedCities.length > 0;
const hasSearchParams = searchQuery.trim().length > 0 || selectedCities.length > 0;
const searchPrompt = isSearchView && !hasSearchParams;
const feed = useFeedPosts();
const myPosts = useMyPosts(user?.id);
const cityFeed = useCityFeed(selectedCities);
const profileData = useMemo(() => {
if (!user) return {};
const nameParts = (user.name || "").trim().split(/\s+/);
const firstName = user.firstName || nameParts[0] || "";
const lastName = user.lastName || nameParts.slice(1).join(" ") || "";
return {
login: user.login || "",
firstName,
lastName,
email: user.email || "",
phone: user.phone || "",
avatar: getImageUrl(user.avatar),
id: user.id,
};
}, [user]);
const refreshCurrentList = useCallback(() => {
if (selectedCities.length > 0) {
cityFeed.searchCities(selectedCities);
} else {
feed.loadFeed();
}
}, [selectedCities, cityFeed, feed]);
const actions = useFeedActions({
refresh: refreshCurrentList,
loadMyPosts: myPosts.loadMyPosts,
onAuthRequired: () => modals.open("auth"),
isAuthenticated,
t,
setLiked,
setLikeCountAdj,
});
// Загружаем лайки пользователя при авторизации
useEffect(() => {
if (isAuthenticated && token) {
loadFavorites().then(favoriteIds => {
const s = new Set(favoriteIds);
actions.syncLiked(s);
setLiked(s);
});
} else {
actions.syncLiked(new Set());
setLiked(new Set());
}
setLikeCountAdj({});
}, [isAuthenticated, token, loadFavorites]);
// Открываем модалку аутентификации если пришли с лендинга
useEffect(() => {
if (location.state?.openAuth) {
modals.open("auth");
window.history.replaceState({}, document.title);
}
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
// При загрузке приложения подтягиваем свежие данные профиля (аватар и т.д.)
// с бэка, чтобы нижняя навигация и профиль отражали изменения с других
// устройств, а не устаревший кэш контекста. Дергаем один раз на пользователя.
useEffect(() => {
if (isAuthenticated && user?.id && profileFetchedFor.current !== user.id) {
profileFetchedFor.current = user.id;
fetchProfile(user.id).then(result => {
if (result.success) updateUser(result.data);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user?.id]);
// При открытии профиля подтягиваем свежие данные (аватар/профиль) с бэка,
// чтобы изменения с других устройств отражались, а не брались из кэша контекста
useEffect(() => {
if (modals.isOpen("profile") && isAuthenticated && user?.id) {
fetchProfile(user.id).then(result => {
if (result.success) updateUser(result.data);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modals.isOpen("profile"), isAuthenticated, user?.id]);
// Загрузка основной ленты (не в режиме поиска)
useEffect(() => {
if (!isSearchView && selectedCities.length === 0 && !searchQuery.trim()) {
feed.loadFeed(0, "", false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSearchView]);
// Дебаунс поиска: только когда в search view и есть параметры
useEffect(() => {
if (!isSearchView) return;
if (!hasSearchParams) return;
const timer = setTimeout(() => {
feed.loadFeed(0, searchQuery, false);
}, 400);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedCities, isSearchView]);
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда
useEffect(() => {
if (activeNav === "myads" && isAuthenticated && user?.id) {
myPosts.loadMyPosts();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNav, isAuthenticated, user?.id]);
// Загрузка избранных постов при переходе во вкладку «Избранное»
useEffect(() => {
if (activeNav !== "favorites") return;
if (!isAuthenticated || !token) return;
let cancelled = false;
setFavoritesLoading(true);
loadFavoritePosts().then(posts => {
if (cancelled) return;
setFavoritePosts(posts);
setFavoritesLoading(false);
});
return () => { cancelled = true; };
}, [activeNav, isAuthenticated, token, loadFavoritePosts]);
const sentinelRef = useInfiniteScroll(
() => feed.loadFeed(feed.page + 1, searchQuery, true),
activeNav === "home" && selectedCities.length === 0 && feed.hasMore && !feed.loading && !feed.loadingMore
);
const handleImageClick = useCallback((images, index, ad) => {
if (images?.length) modals.open("viewer", { images, index, ad });
}, [modals]);
const handleCreatePost = useCallback(() => {
if (!isAuthenticated) modals.open("auth");
else modals.open("create");
}, [isAuthenticated, modals]);
const handleEdit = useCallback((ad) => {
if (!isAuthenticated) modals.open("auth");
else modals.open("edit", ad);
}, [isAuthenticated, modals]);
const handleHomeClick = useCallback(() => {
setActiveNav("home");
setSearchQuery("");
setSelectedCities([]);
if (isSearchView || isMapView) navigate("/feed");
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [isSearchView, isMapView, navigate]);
const handleSearchClick = useCallback(() => {
setActiveNav("home");
setSearchQuery("");
setSelectedCities([]);
if (!isSearchView) navigate("/feed?view=search");
else if (isMapView) navigate("/feed?view=search");
feedRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [isSearchView, isMapView, navigate]);
// Карта с объявлениями
const saveFeedState = useCallback(() => {
feedStateRef.current = {
scrollTop: feedRef.current?.scrollTop ?? 0,
url: `${location.pathname}${location.search}`,
activeNav,
};
restoreScrollRef.current = true;
}, [location.pathname, location.search, activeNav]);
const handleMapClick = useCallback(() => {
saveFeedState();
setActiveNav("map");
if (!isMapView) navigate("/feed?view=map");
}, [isMapView, navigate, saveFeedState]);
const handleMapPostClick = useCallback((postId) => {
modals.open("postDetail", { postId });
}, [modals]);
// Возврат с карты на ленту — на то же место, где юзер был до открытия карты
const handleMapBack = useCallback(() => {
const { url, activeNav: prevNav } = feedStateRef.current;
setActiveNav(prevNav || "home");
navigate(url || "/feed", { replace: true });
}, [navigate]);
// Восстанавливаем прокрутку ленты после возврата с карты
useEffect(() => {
if (isMapView || !restoreScrollRef.current) return;
const el = feedRef.current;
if (el) el.scrollTop = feedStateRef.current.scrollTop;
restoreScrollRef.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapView, feed.ads, cityFeed.cityAds, feed.loading, cityFeed.cityLoading]);
// Клик по локации на карточке — карта с фокусом на этом объявлении
const handleLocationClick = useCallback((ad) => {
saveFeedState();
const params = new URLSearchParams({ view: "map", post: ad.id });
if (ad.latitude != null && ad.longitude != null) {
params.set("lat", ad.latitude);
params.set("lng", ad.longitude);
}
setActiveNav("map");
navigate(`/feed?${params.toString()}`);
}, [navigate, saveFeedState]);
const handleProfileClick = useCallback(() => {
if (!isAuthenticated) modals.open("auth");
else modals.open("profile");
}, [isAuthenticated, modals]);
const filteredAds = useMemo(() => {
let result;
if (activeNav === "myads") {
result = myPosts.myAds;
} else if (activeNav === "favorites") {
result = favoritePosts;
} else if (selectedCities.length > 0) {
result = cityFeed.cityAds;
} else {
result = feed.ads;
}
if (activeNav === "items") {
result = result.filter(ad => ["Вещь", "Другое"].includes(ad.category));
} else if (activeNav === "pets") {
result = result.filter(ad => ad.category === "Питомец");
}
if (typeFilter) {
result = result.filter(ad => ad.type === typeFilter);
}
return result;
}, [activeNav, myPosts.myAds, favoritePosts, cityFeed.cityAds, feed.ads, selectedCities, typeFilter]);
const listLoading = activeNav === "myads"
? myPosts.loading
: activeNav === "favorites"
? favoritesLoading
: feed.loading;
const listError = activeNav === "myads"
? myPosts.error
: activeNav === "favorites"
? favoritesError
: feed.error;
return (
<div className={`f-app${sidebarOpen ? " f-sb-open" : ""}${isSearchView ? " f-search-view" : ""}${isMapView ? " f-map-view" : ""}`}>
<Sidebar
open={sidebarOpen}
activeNav={activeNav}
onClose={() => setSidebarOpen(false)}
onNavClick={(key) => {
setActiveNav(key);
setSearchQuery("");
setSelectedCities([]);
if (isMapView || isSearchView) navigate("/feed");
}}
isAuthenticated={isAuthenticated}
onLoginClick={() => modals.open("auth")}
onRulesClick={() => modals.open("rules")}
onReviewsClick={() => navigate("/reviews")}
onCreatePost={handleCreatePost}
onProfileClick={() => modals.open("profile")}
profileData={profileData}
/>
<div className="f-main f-main-feed">
{isSearchView && (
<FeedTopBar
onMenuClick={() => setSidebarOpen(v => !v)}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
/>
)}
<div className="f-search-panel">
<FeedSearchBar value={searchQuery} onChange={setSearchQuery} t={t} />
{activeNav === "home" && (
<div className="f-city-filter-wrap">
<CityFilter cities={cities} value={selectedCities} onChange={setSelectedCities} />
</div>
)}
</div>
<div className="f-wrap">
{isMapView ? (
<MapView
focusPostId={focusPostId}
focusLat={focusLat}
focusLng={focusLng}
onPostClick={handleMapPostClick}
onBack={handleMapBack}
/>
) : (
<>
<div className="f-feed" ref={feedRef}>
{!isSearchView && (
<FeedTopBar
onMenuClick={() => setSidebarOpen(v => !v)}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
/>
)}
<FeedContent
loading={listLoading}
cityLoading={cityFeed.cityLoading}
citySearchActive={selectedCities.length > 0}
error={listError}
filteredAds={filteredAds}
activeNav={activeNav}
searchPrompt={searchPrompt}
liked={liked}
likeCountAdj={likeCountAdj}
onLike={actions.toggleLike}
onEdit={handleEdit}
onDelete={actions.handleDelete}
onMarkFound={actions.handleMarkFound}
onImageClick={handleImageClick}
onLocationClick={handleLocationClick}
onAuthRequired={() => modals.open("auth")}
t={t}
/>
{activeNav === "home" && selectedCities.length === 0 && !feed.loading && (
<div ref={sentinelRef} style={{ height: "1px" }} />
)}
{feed.loadingMore && (
<div className="f-center" style={{ padding: "1rem" }}>
<div className="f-spinner" />
</div>
)}
</div>
<RightSidebar
ads={filteredAds}
profileData={profileData}
isAuthenticated={isAuthenticated}
onProfileClick={() => modals.open("profile")}
onLoginClick={() => modals.open("auth")}
onNavClick={(key) => {
setActiveNav(key);
setSearchQuery("");
setSelectedCities([]);
if (isMapView || isSearchView) navigate("/feed");
}}
/>
</>
)}
</div>
</div>
{modals.isOpen("create") && isAuthenticated && (
<CreateUpdatePost onClose={modals.close} onSuccess={actions.handleNewAd} />
)}
{modals.isOpen("edit") && isAuthenticated && (
<CreateUpdatePost
initialData={modals.modal.payload}
onClose={modals.close}
onSuccess={actions.handleEditSave}
/>
)}
{modals.isOpen("profile") && isAuthenticated && (
<ProfileModal onClose={modals.close} initialData={profileData} />
)}
{modals.isOpen("auth") && <AuthModal onClose={modals.close} />}
{modals.isOpen("rules") && <RulesModal onClose={modals.close} />}
{modals.isOpen("viewer") && (
<ImageViewer
images={modals.modal.payload.images}
initialIndex={modals.modal.payload.index}
onClose={modals.close}
postData={modals.modal.payload.ad}
/>
)}
{modals.isOpen("postDetail") && (
<PostDetailModal
postId={modals.modal.payload.postId}
onClose={modals.close}
onImageClick={(images, index, ad) => {
modals.open("viewer", { images, index, ad });
}}
onLocationClick={(ad) => {
modals.close();
handleLocationClick(ad);
}}
onAuthRequired={() => modals.open("auth")}
/>
)}
<BottomNav
activeTab={isMapView ? "map" : isSearchView ? "search" : "home"}
hasFilters={hasActiveFilters}
profileData={profileData}
onHome={handleHomeClick}
onMap={handleMapClick}
onCreatePost={handleCreatePost}
onSearch={handleSearchClick}
onProfile={handleProfileClick}
t={t}
/>
</div>
);
}

View File

@@ -13,6 +13,7 @@
--err: #ed4956;
--bg-app: #fafafa;
--bg-rgb: 250, 250, 250;
--bg-card: #ffffff;
--bg-card-popup: #ffffff;
--bg-surface: #ffffff;
@@ -35,6 +36,7 @@
--err: #ed4956;
--bg-app: #000000;
--bg-rgb: 0, 0, 0;
--bg-card: #1b1b1b;
--bg-card-popup: #262626;
--bg-surface: #000000;

View File

@@ -1,3 +1,17 @@
/* ─────────────────────────────────────────────────────────────────────
СТЕКЛЯННАЯ ШАПКА + ЗАТУХАНИЕ ЛЕНТЫ (Telegram Web style)
─────────────────────────────────────────────────────────────────────
Регистрация кастомного свойства: именно благодаря @property смена
--feed-fade-height анимируется transition'ом. В браузерах без поддержки
@property значение просто меняется мгновенно (graceful degradation).
*/
@property --feed-fade-height {
syntax: '<length>';
inherits: false;
initial-value: 48px;
}
/* ── App Shell (Instagram-style) ── */
.f-app {
display: flex; height: 100vh; height: 100dvh;
@@ -19,17 +33,46 @@
}
/* ── Topbar (telegram-style; mobile only; desktop uses the nav sidebar) ──
Фон отсутствует полностью: стеклянными выглядят только сами кнопки. */
«Стекло»: полупрозрачный фон темы + мягкий blur по содержимому под шапкой.
blur/saturate явно умеренные — сильно агрессивное размытие даёт
пересвеченное «стекло» (blur 12px + saturate ~135%). */
.f-topbar {
position: sticky;
top: 0;
z-index: 100;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
padding-top: calc(8px + env(safe-area-inset-top));
/* Без backdrop-filter: blur имеет жёсткую границу по кромке прямоугольника,
которую маской не убрать. Плавность даёт только сквозной альфа-градиент,
сходящийся к 0 уже к низу бара. */
background: linear-gradient(
to bottom,
rgba(var(--bg-rgb), 0.14) 0%,
rgba(var(--bg-rgb), 0.07) 55%,
rgba(var(--bg-rgb), 0.05) 100%
);
transition: transform 0.25s ease, background-color 0.25s ease;
}
/* Продолжение того же сквозного градиента ниже бара: цвет уходит в 0,
резкой границы нет, «плашки» не видно вообще. */
.f-topbar::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 100%;
height: 64px;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(var(--bg-rgb), 0.05) 0%,
rgba(var(--bg-rgb), 0) 100%
);
}
/* Кнопка-логотип по центру: wordmark «Находка» в округлой стеклянной капсуле */
@@ -206,18 +249,22 @@
.f-topbar-dropdown-wrap {
position: relative;
}
/* В ленте шапка липнет к верху скроллера: контент прокручивается под ней
и «замыливается» через backdrop-filter на .f-topbar */
.f-feed > .f-topbar-dropdown-wrap {
position: sticky;
top: 0;
z-index: 100;
margin: -20px -16px 0;
.f-topbar-dropdown-wrap .f-topbar.f-topbar-hidden {
/* Плавное скрытие шапки при скролле вниз (translateY вверх, за кромку .f-feed).
Висим на самом .f-topbar, а НЕ на обёртке: transform на обёртке создал бы
containing block для position:fixed оверлея дропдауна и сломал бы клик вне. */
transform: translateY(-100%);
}
@media (max-width: 900px) {
.f-feed > .f-topbar-dropdown-wrap {
margin: -10px -10px 0;
}
/* В ленте шапка лежит ПОВЕРХ маскированного скролл-контейнера (.f-feed-scroll):
по z-index она выше «слоя затухания», но ниже, чем сам контент не бывает
никогда — жёстко прижата к верху .f-feed. */
.f-feed > .f-topbar-dropdown-wrap {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
margin: 0;
}
.f-topbar-dropdown-overlay {
@@ -577,14 +624,45 @@
/* ── Feed Wrap + Scroll ── */
.f-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }
/* Внешний контейнер ленты: не скроллится, лишь позиционирует слои.
Сверху — стеклянная шапка (абсолютный слой), под ней — маскированный
скролл-список; такой порядок даёт нужную иерархию z-index. */
.f-feed {
flex: 1; overflow-y: scroll; overflow-x: hidden; overscroll-behavior: contain; min-height: 0;
padding: 20px 16px; display: flex; flex-direction: column; gap: 16px;
width: 100%; max-width: 660px; margin: 0 auto;
position: relative;
overflow: hidden;
flex: 1;
min-height: 0;
width: 100%;
max-width: 660px;
margin: 0 auto;
}
.f-feed::-webkit-scrollbar { width: 4px; }
.f-feed::-webkit-scrollbar-track { background: transparent; }
.f-feed::-webkit-scrollbar-thumb { background: var(--muted2); border-radius: 2px; }
/* ── Слой затухания ленты ──
mask-image на самом контейнере списка (НЕ blur, НЕ opacity карточек):
верхние элементы, доезжающие до кромки под FeedTopBar, «растворяются».
Маска якорена к border-box скролл-контейнера, т.е. НЕ уезжает вместе
с карточками — зона затухания всегда под шапкой. Высота зоны задаётся
--feed-fade-height (управляется из React/ResizeObserver) и через
@property + transition анимируется при изменении.
padding-top синхронизирован с этой же переменной, чтобы первая карточка
в состоянии покоя не проваливалась под шапку. */
.f-feed-scroll {
position: absolute;
inset: 0;
overflow-y: scroll;
overflow-x: hidden;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
gap: 16px;
padding: calc(var(--feed-fade-height, 48px) + 20px) 16px 20px;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black var(--feed-fade-height, 48px));
mask-image: linear-gradient(to bottom, transparent 0, black var(--feed-fade-height, 48px));
transition: --feed-fade-height 0.2s ease;
}
.f-feed-scroll::-webkit-scrollbar { width: 4px; }
.f-feed-scroll::-webkit-scrollbar-track { background: transparent; }
.f-feed-scroll::-webkit-scrollbar-thumb { background: var(--muted2); border-radius: 2px; }
@media (min-width: 900px) {
.f-main-feed .f-topbar {
@@ -611,8 +689,8 @@
bottom: calc(84px + env(safe-area-inset-bottom));
}
.f-feed {
padding: 10px;
.f-feed-scroll {
padding: calc(var(--feed-fade-height, 48px) + 10px) 10px calc(150px + env(safe-area-inset-bottom));
gap: 12px;
}
@@ -645,11 +723,6 @@
margin-top: 4px; border-radius: 999px;
}
.f-topbar-dropdown-item { padding: 7px 13px; font-size: 0.8rem; }
/* Нижняя панель fixed — освобождаем место в конце ленты */
.f-feed {
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
}
@media (max-width: 600px) {