diff --git a/react_front/src/main.tsx b/react_front/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/react_front/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/src/components/feed/FeedBlurMask.jsx b/src/components/feed/FeedBlurMask.jsx
new file mode 100644
index 0000000..d3530c7
--- /dev/null
+++ b/src/components/feed/FeedBlurMask.jsx
@@ -0,0 +1,28 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Отдельный блюр-слой поверх ленты, независим от FeedTopBar.
+ * Прижат к верху общего контейнера, высота подстраивается под
+ * реальную высоту topBar (включая safe-area на iOS).
+ */
+export default function FeedBlurMask({ topBarRef }) {
+ const [height, setHeight] = useState(56);
+
+ useEffect(() => {
+ const el = topBarRef?.current;
+ if (!el) return;
+ const update = () => setHeight(el.getBoundingClientRect().height || 56);
+ update();
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [topBarRef]);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/feed/FeedTopBar.tsx b/src/components/feed/FeedTopBar.tsx
new file mode 100644
index 0000000..9d1894d
--- /dev/null
+++ b/src/components/feed/FeedTopBar.tsx
@@ -0,0 +1,150 @@
+import { useState, useRef, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { useTranslation } from "react-i18next";
+import MenuIconBase from "../icons/MenuIcon";
+import NahodkaLogoBase from "../NahodkaLogo";
+import type { Ref, ComponentType } from "react";
+
+// Legacy .jsx-компоненты: TS инферирует их сигнатуры из JS неточно —
+// приводим к нормальному виду для корректной проверки пропсов.
+const NahodkaLogo = NahodkaLogoBase as unknown as ComponentType<{
+ className?: string;
+ width?: number;
+ height?: number;
+}>;
+const MenuIcon = MenuIconBase as unknown as ComponentType<{ className?: string }>;
+
+interface FeedTopBarProps {
+ onMenuClick: () => void;
+ typeFilter: "loss" | "found" | null;
+ onTypeFilterChange: (key: "loss" | "found" | null) => void;
+ hidden?: boolean;
+ /** Реф на сам стеклянный бар — родитель меряет его высоту для CSS-переменной --feed-fade-height */
+ topBarRef?: Ref;
+}
+
+/**
+ * FeedTopBar — «стеклянная» шапка ленты (Telegram-style).
+ *
+ * Эффекты (разделены, чтобы было понятно что за что отвечает):
+ * - стекло: backdrop-filter blur+saturate + полупрозрачный фон (см. .f-topbar в layout.css);
+ * - плавное скрытие/появление при скролле: родитель передаёт `hidden`
+ * (значение из useScrollDirection), CSS делает transform: translateY(-100%)
+ * — скрывается вверх, контент ленты проплывает под ним;
+ * - self-contained: вся логика дропдауна фильтра типа осталась здесь.
+ */
+export default function FeedTopBar({
+ onMenuClick,
+ typeFilter,
+ onTypeFilterChange,
+ hidden = false,
+ topBarRef,
+}: FeedTopBarProps) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const menuRef = useRef(null);
+ const btnRef = useRef(null);
+
+ const options: { key: "loss" | "found" | null; label: string }[] = [
+ { 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: MouseEvent) => {
+ if (
+ menuRef.current && !menuRef.current.contains(e.target as Node) &&
+ btnRef.current && !btnRef.current.contains(e.target as Node)
+ ) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [open]);
+
+ const handleSelect = (key: "loss" | "found" | null) => {
+ onTypeFilterChange(key);
+ setOpen(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {open && (
+ setOpen(false)}
+ />
+ )}
+
+
+
+ {open && (
+
+ {options.map((opt) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/feed/PullToRefresh.jsx b/src/components/feed/PullToRefresh.jsx
new file mode 100644
index 0000000..30ebc6e
--- /dev/null
+++ b/src/components/feed/PullToRefresh.jsx
@@ -0,0 +1,111 @@
+import { useEffect, useRef, useState } from "react";
+
+const THRESHOLD = 64;
+const MAX_PULL = 120;
+
+/**
+ * Pull-to-refresh для ленты: тянем ленту вниз, пока она наверху, —
+ * появляется круговой индикатор, отпускаем — данные обновляются.
+ *
+ * containerRef — скролл-контейнер ленты (.f-feed-scroll).
+ * onRefresh — асинхронный колбэк; индикатор крутится, пока его промис
+ * не разрешится, затем прячется.
+ */
+export default function PullToRefresh({ containerRef, onRefresh, disabled = false }) {
+ const [pull, setPullState] = useState(0);
+ const [refreshing, setRefreshing] = useState(false);
+ const [dragging, setDragging] = useState(false);
+ const pullRef = useRef(0);
+ const startY = useRef(null);
+ const active = useRef(false);
+ const refreshRef = useRef(onRefresh);
+ refreshRef.current = onRefresh;
+
+ const setPull = (value) => {
+ pullRef.current = value;
+ setPullState(value);
+ };
+
+ const visible = pull > 0 || refreshing;
+ const rotate = refreshing ? 0 : pull * 2;
+
+ // Пока тянем или обновляемся — снимаем маску затухания с контейнера,
+ // иначе индикатор (он первый элемент внутри скролла) растворится в маске.
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ el.classList.toggle("f-ptr-active", visible);
+ }, [visible, containerRef]);
+
+ useEffect(() => {
+ if (disabled) {
+ setPull(0);
+ setRefreshing(false);
+ }
+ }, [disabled]);
+
+ useEffect(() => {
+ if (disabled) return;
+ const el = containerRef.current;
+ if (!el) return;
+
+ const onStart = (e) => {
+ if (el.scrollTop <= 0 && !refreshing) {
+ startY.current = e.touches[0].clientY;
+ active.current = true;
+ setDragging(true);
+ } else {
+ startY.current = null;
+ active.current = false;
+ }
+ };
+
+ const onMove = (e) => {
+ if (!active.current || startY.current == null) return;
+ const dy = (e.touches[0].clientY - startY.current) * 0.5;
+ if (dy > 0 && el.scrollTop <= 0) {
+ setPull(Math.min(dy, MAX_PULL));
+ } else {
+ setPull(0);
+ }
+ };
+
+ const onEnd = async () => {
+ if (!active.current) return;
+ active.current = false;
+ startY.current = null;
+ setDragging(false);
+ if (pullRef.current >= THRESHOLD && !refreshing) {
+ setRefreshing(true);
+ try {
+ await refreshRef.current?.();
+ } finally {
+ setRefreshing(false);
+ }
+ }
+ setPull(0);
+ };
+
+ el.addEventListener("touchstart", onStart, { passive: true });
+ el.addEventListener("touchmove", onMove, { passive: true });
+ el.addEventListener("touchend", onEnd, { passive: true });
+ el.addEventListener("touchcancel", onEnd, { passive: true });
+ return () => {
+ el.removeEventListener("touchstart", onStart);
+ el.removeEventListener("touchmove", onMove);
+ el.removeEventListener("touchend", onEnd);
+ el.removeEventListener("touchcancel", onEnd);
+ };
+ }, [containerRef, disabled, refreshing]);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/feed/RewardPopup.jsx b/src/components/feed/RewardPopup.jsx
new file mode 100644
index 0000000..17b4f7f
--- /dev/null
+++ b/src/components/feed/RewardPopup.jsx
@@ -0,0 +1,69 @@
+import { useEffect, useRef, useLayoutEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+
+/**
+ * Всплывающее окно с текстом вознаграждения (rewardText).
+ * Открывается под/рядом с кнопкой-якорем, закрывается по клику мимо или Esc.
+ */
+export default function RewardPopup({ text, anchorRef, onClose }) {
+ const { t } = useTranslation();
+ const ref = useRef(null);
+ const [coords, setCoords] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!anchorRef?.current || !ref.current) return;
+ const anchorRect = anchorRef.current.getBoundingClientRect();
+ const popupRect = ref.current.getBoundingClientRect();
+ const gap = 8;
+ const margin = 10;
+
+ // Всплываем слева сверху от кнопки (как календарь): низ попапа — у верха якоря,
+ // правая грань попапа выравнивается по правой грани кнопки.
+ let top = anchorRect.top + window.scrollY - popupRect.height - gap;
+ const minTop = window.scrollY + margin;
+ if (top < minTop) top = minTop;
+
+ let left = anchorRect.right + window.scrollX - popupRect.width;
+ const minLeft = window.scrollX + margin;
+ if (left < minLeft) left = minLeft;
+
+ setCoords({ top, left });
+ }, [anchorRef]);
+
+ useEffect(() => {
+ const onDown = (e) => {
+ const inAnchor = anchorRef?.current?.contains(e.target);
+ const inPopup = ref.current?.contains(e.target);
+ if (!inAnchor && !inPopup) onClose();
+ };
+ const onKey = (e) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("mousedown", onDown);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onDown);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [onClose, anchorRef]);
+
+ return createPortal(
+
+
💛 {t("postCard.reward")}
+
{text}
+
,
+ document.body
+ );
+}
\ No newline at end of file
diff --git a/src/config/feedConstants.js b/src/config/feedConstants.js
index 4a3cd2b..75d4934 100644
--- a/src/config/feedConstants.js
+++ b/src/config/feedConstants.js
@@ -27,7 +27,8 @@ export const EMPTY_FORM = {
date: "",
address: "",
rulesAccepted: false,
- reward: false
+ reward: false,
+ rewardText: ""
};
export const MAX_PHOTO_SIZE_MB = 5;
diff --git a/src/hooks/useProfile.js b/src/hooks/useProfile.js
new file mode 100644
index 0000000..101cd5c
--- /dev/null
+++ b/src/hooks/useProfile.js
@@ -0,0 +1,49 @@
+import { useState, useCallback } from "react";
+import { useTranslation } from 'react-i18next';
+import { API_URL } from "../config/authConstants";
+import { apiFetch } from "../api/apiClient";
+
+/**
+ * useProfile — хук для получения свежих данных профиля с бэка.
+ * Делает GET /api/v1/users/{id} и возвращает актуальные данные пользователя
+ * (включая аватар), которые могут устареть в локальном контексте, если
+ * профиль менялся с другого устройства.
+ *
+ * fetchProfile возвращает { success, data, error }.
+ */
+export function useProfile() {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const fetchProfile = useCallback(async (userId) => {
+ if (!userId) {
+ const message = t('profile.fetchMissingUserId');
+ setError(message);
+ return { success: false, data: null, error: message };
+ }
+
+ setLoading(true);
+ setError("");
+
+ try {
+ const res = await apiFetch(`${API_URL}/api/v1/users/${userId}`);
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.message || t('profile.fetchErrorWithStatus', { status: res.status }));
+ }
+
+ const user = await res.json();
+ return { success: true, data: user, error: null };
+ } catch (err) {
+ const message = err.message || t('profile.fetchError');
+ setError(message);
+ return { success: false, data: null, error: message };
+ } finally {
+ setLoading(false);
+ }
+ }, [t]);
+
+ return { fetchProfile, loading, error };
+}
diff --git a/src/hooks/useScrollDirection.ts b/src/hooks/useScrollDirection.ts
new file mode 100644
index 0000000..d49517e
--- /dev/null
+++ b/src/hooks/useScrollDirection.ts
@@ -0,0 +1,71 @@
+import { useEffect, useRef, useState } from "react";
+import type { RefObject } from "react";
+
+export type ScrollDirection = "up" | "down";
+
+/**
+ * useScrollDirection — отслеживает направление скролла контейнера `containerRef`
+ * и сообщает, когда тулбар должен быть скрыт.
+ *
+ * Логика (как в Telegram Web):
+ * - при скролле вниз тулбар прячется (hidden = true);
+ * - при скролле вверх (или подъезде в самое начало списка) — показывается;
+ * - есть порог `showThreshold` у вершины, чтобы тулбар появлялся не раньше,
+ * чем контент вернулся достаточно близко к началу.
+ *
+ * requestAnimationFrame + ref'ы: обработчик скролла НЕ триггерит ре-рендер
+ * на каждое событие — состояние меняется только когда скрытость реально
+ * пересекает границу, всё остальное живёт в ref'ах.
+ */
+export default function useScrollDirection(
+ containerRef: RefObject,
+ { hideThreshold = 12, showThreshold = 88 }: { hideThreshold?: number; showThreshold?: number } = {},
+): boolean {
+ const [hidden, setHidden] = useState(false);
+
+ const hiddenRef = useRef(false);
+ const lastYRef = useRef(0);
+ const rafRef = useRef(null);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ lastYRef.current = el.scrollTop;
+
+ const onScroll = () => {
+ if (rafRef.current != null) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ const y = el.scrollTop;
+
+ const maxScroll = el.scrollHeight - el.clientHeight;
+ const isAtTop = y <= showThreshold;
+ const isAtBottom = maxScroll > 0 && y >= maxScroll - showThreshold;
+ // Без сенсорных/фрикционных возражений: крошечные рывки не считаем за жест
+ const dy = Math.abs(y - lastYRef.current);
+
+ let next = hiddenRef.current;
+ if (isAtTop || isAtBottom) {
+ next = false;
+ } else if (dy >= hideThreshold) {
+ next = y > lastYRef.current;
+ }
+ lastYRef.current = y;
+
+ if (next !== hiddenRef.current) {
+ hiddenRef.current = next;
+ setHidden(next);
+ }
+ });
+ };
+
+ el.addEventListener("scroll", onScroll, { passive: true });
+ return () => {
+ el.removeEventListener("scroll", onScroll);
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
+ };
+ }, [containerRef, hideThreshold, showThreshold]);
+
+ return hidden;
+}
\ No newline at end of file
diff --git a/src/locales/en.json b/src/locales/en.json
index 4b76c80..3d8808f 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -115,6 +115,8 @@
"addressPlaceholder": "E.g. 123 Main St, New York",
"phoneLabel": "Phone number",
"rewardCheckbox": "I am willing to offer a reward for the return",
+ "rewardLabel": "Reward amount",
+ "rewardDefault": "To be discussed",
"phonePlaceholder": "+1 (___) ___-__-__",
"saveChanges": "Save changes",
"publish": "Publish ad",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index bc0104b..0ed6dd4 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -115,6 +115,8 @@
"addressPlaceholder": "Например: Москва, ул. Ленина, 5",
"phoneLabel": "Номер телефона",
"rewardCheckbox": "Вознаграждение за возврат",
+ "rewardLabel": "Размер вознаграждения",
+ "rewardDefault": "Обсуждается",
"phonePlaceholder": "+7 (___) ___-__-__",
"saveChanges": "Сохранить изменения",
"publish": "Разместить объявление",
diff --git a/src/pages/CreateUpdatePost.jsx b/src/pages/CreateUpdatePost.jsx
index 7098492..7c4571d 100644
--- a/src/pages/CreateUpdatePost.jsx
+++ b/src/pages/CreateUpdatePost.jsx
@@ -34,7 +34,9 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
phone: initialData.phone || "",
address: initialData.city || "",
rulesAccepted: true,
- reward: initialData.reward ?? false
+ reward: initialData.reward ?? false,
+ rewardText: initialData.rewardText
+ || (initialData.reward ? t('post.rewardDefault') : "")
}
: EMPTY_FORM
);
@@ -123,6 +125,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
data.append("category", form.category);
data.append("phone", form.phone.trim());
data.append("reward", form.reward ? "true" : "false");
+ if (form.reward) data.append("rewardText", (form.rewardText || "").trim());
if (form.date) data.append("date", form.date);
const files = getFilesForUpload();
@@ -348,7 +351,13 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
set("reward", e.target.checked)}
+ onChange={e => {
+ const checked = e.target.checked;
+ set("reward", checked);
+ if (checked && !(form.rewardText || "").trim()) {
+ set("rewardText", t('post.rewardDefault'));
+ }
+ }}
/>
@@ -359,6 +368,21 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
+ {form.reward && (
+
+
+ set("rewardText", "")} visible={!!form.rewardText}>
+ set("rewardText", e.target.value)}
+ />
+
+
+ )}
+
{/* Чекбокс согласия с правилами */}
)}
{ad.reward && (
-
- {t("postCard.reward")}
-
+ <>
+
+ {showReward && (
+ setShowReward(false)}
+ />
+ )}
+ >
)}
)}
diff --git a/src/styles/NakhodkaLanding.css b/src/styles/NakhodkaLanding.css
index f2f9ac6..7d13c21 100644
--- a/src/styles/NakhodkaLanding.css
+++ b/src/styles/NakhodkaLanding.css
@@ -148,7 +148,7 @@
}
.nk-logo svg {
- height: 80px;
+ height: 40px;
width: auto;
display: block;
}
@@ -455,7 +455,7 @@
}
.nk-logo svg {
- height: 64px;
+ height: 32px;
}
.nk-hero-text {
diff --git a/src/styles/card.css b/src/styles/card.css
index 30de345..5e029c0 100644
--- a/src/styles/card.css
+++ b/src/styles/card.css
@@ -110,8 +110,12 @@
.f-card-reward-text {
display: inline-block;
- font-size: .74rem; font-weight: 600; letter-spacing: .02em;
white-space: nowrap;
+}
+
+/* Золотой переливающийся текст внутри кнопки-пилюли */
+.f-reward-btn-label {
+ font-size: .74rem; font-weight: 600; letter-spacing: .02em;
background: linear-gradient(110deg, #e6b800 30%, #ffec99 50%, #e6b800 70%);
background-size: 200% 100%;
-webkit-background-clip: text;
@@ -124,6 +128,73 @@
100% { background-position: -200% 0; }
}
+/* Кнопка «Вознаграждение» в виде пилюли в золотой обводке — видно, что
+ на неё можно нажать. Заливка лёгкая, сам перелив живёт в .f-reward-btn-label */
+button.f-card-reward-btn {
+ border: 1px solid rgba(230, 184, 0, 0.5);
+ border-radius: 999px;
+ background: rgba(230, 184, 0, 0.08);
+ padding: 4px 12px;
+ cursor: pointer;
+ font-family: var(--font);
+ line-height: inherit;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
+ transition: transform .15s ease, filter .15s ease, border-color .15s ease, background .15s ease;
+}
+button.f-card-reward-btn:hover {
+ transform: scale(1.08);
+ border-color: rgba(230, 184, 0, 0.85);
+ background: rgba(230, 184, 0, 0.16);
+ filter: brightness(1.1) drop-shadow(0 0 6px rgba(230, 184, 0, 0.45));
+}
+button.f-card-reward-btn:active { transform: scale(.94); }
+button.f-card-reward-btn:focus-visible {
+ outline: 2px solid rgba(230,184,0,.7);
+ outline-offset: 2px;
+ border-radius: 999px;
+}
+
+/* ── Всплывающее окно вознаграждения ── */
+.f-reward-popup {
+ max-width: 260px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: linear-gradient(150deg, rgba(48,39,16,.97), rgba(22,18,10,.98));
+ backdrop-filter: blur(10px) saturate(140%);
+ -webkit-backdrop-filter: blur(10px) saturate(140%);
+ border: 1px solid rgba(230,184,0,.55);
+ box-shadow:
+ 0 8px 30px rgba(0,0,0,.4),
+ 0 0 0 1px rgba(230,184,0,.12),
+ 0 0 26px rgba(230,184,0,.2);
+ animation: f-reward-pop-in .3s cubic-bezier(.34,1.56,.64,1);
+}
+.f-reward-popup-title {
+ font-size: .74rem;
+ font-weight: 900;
+ letter-spacing: .05em;
+ text-transform: uppercase;
+ margin-bottom: 7px;
+ white-space: nowrap;
+ background: linear-gradient(110deg, #e6b800 25%, #ffec99 50%, #e6b800 75%);
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ animation: f-reward-shimmer 3.2s ease-in-out infinite;
+}
+.f-reward-popup-text {
+ font-size: .92rem;
+ line-height: 1.5;
+ color: #ffe9a8;
+ word-break: break-word;
+ overflow-wrap: break-word;
+}
+@keyframes f-reward-pop-in {
+ from { opacity: 0; transform: translateY(-8px) scale(.85); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
/* ── Likes ── */
.f-card-likes {
padding: 2px 16px;
diff --git a/src/styles/layout.css b/src/styles/layout.css
index 8d4c46c..6e4ac91 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -664,6 +664,58 @@
.f-feed-scroll::-webkit-scrollbar-track { background: transparent; }
.f-feed-scroll::-webkit-scrollbar-thumb { background: var(--muted2); border-radius: 2px; }
+/* ── Pull-to-refresh ──
+ Индикатор живёт внутри .f-feed-scroll первым элементом и своим
+ текущим ростом «вытягивает» ленту вниз. Чтобы он был виден на фоне
+ карточек во время жеста, на скролл-контейнер вешается класс
+ .f-ptr-active, снимающий mask (иначе верхняя зона «растворяется»). */
+.f-ptr {
+ height: 0;
+ overflow: hidden;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: height 0.45s cubic-bezier(0.22, 1, 0.36, 1);
+}
+/* Пока палец на экране — без transition, чтобы индикатор мгновенно
+ следовал за жестом и не «отставал» от толщины натяжения. */
+.f-ptr.f-ptr-dragging {
+ transition: none;
+}
+/* Пружинистое возвращение к состоянию обновления: лёгкий довод/перелёт */
+.f-ptr.f-ptr-refreshing {
+ transition: height 0.5s cubic-bezier(0.34, 1.3, 0.64, 1);
+}
+.f-ptr-scale {
+ flex-shrink: 0;
+ opacity: 0;
+ transform: scale(0.5);
+ transition:
+ transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.03s,
+ opacity 0.25s ease 0.03s;
+}
+.f-ptr-visible .f-ptr-scale {
+ opacity: 1;
+ transform: scale(1);
+}
+.f-ptr-spinner {
+ width: 26px;
+ height: 26px;
+ border: 3px solid rgba(var(--teal-rgb), 0.25);
+ border-top-color: var(--teal);
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.f-ptr-refreshing .f-ptr-spinner {
+ animation: spin 0.7s linear infinite;
+ transition: transform 0.3s ease;
+}
+.f-feed-scroll.f-ptr-active {
+ -webkit-mask-image: none;
+ mask-image: none;
+}
+
@media (min-width: 900px) {
.f-main-feed .f-topbar {
display: none;