feat: add methods catalog, detail pages, BMI calculator

- Create 16 diet/weight methods across 5 categories with full content
- Add MethodsPage with search, filters (category/difficulty/effectiveness)
- Add MethodDetailPage with pros/cons, scientific basis, contraindications
- Add BmiCalculatorPage with visual BMI scale
- Create reusable components: Badge, DifficultyBadge, EffectivenessIndicator,
  BookmarkButton, MethodCard, SearchBar, FilterPanel
- Add useLocalStorage and useBookmarks hooks for localStorage persistence
- Set up React Router with all routes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-11 12:26:09 +03:00
parent 673760f9da
commit b8dd591428
17 changed files with 1435 additions and 1 deletions

View File

@@ -1,12 +1,18 @@
import { Routes, Route } from "react-router-dom";
import { Layout } from "@/components/Layout";
import { HomePage } from "@/pages/HomePage";
import { MethodsPage } from "@/pages/MethodsPage";
import { MethodDetailPage } from "@/pages/MethodDetailPage";
import { BmiCalculatorPage } from "@/pages/BmiCalculatorPage";
export function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="methods" element={<MethodsPage />} />
<Route path="methods/:slug" element={<MethodDetailPage />} />
<Route path="calculator" element={<BmiCalculatorPage />} />
</Route>
</Routes>
);

30
src/components/Badge.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { clsx } from "clsx";
interface BadgeProps {
children: React.ReactNode;
variant?: "default" | "emerald" | "blue" | "violet" | "amber" | "rose";
size?: "sm" | "md";
}
const variantClasses = {
default: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
emerald: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
blue: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
violet: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
amber: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
rose: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300",
} as const;
export function Badge({ children, variant = "default", size = "sm" }: BadgeProps) {
return (
<span
className={clsx(
"inline-flex items-center rounded-full font-medium",
variantClasses[variant],
size === "sm" ? "px-2.5 py-0.5 text-xs" : "px-3 py-1 text-sm",
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,41 @@
import { clsx } from "clsx";
interface BookmarkButtonProps {
isBookmarked: boolean;
onClick: () => void;
size?: "sm" | "md";
}
export function BookmarkButton({ isBookmarked, onClick, size = "sm" }: BookmarkButtonProps) {
return (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick();
}}
className={clsx(
"rounded-full transition-colors",
size === "sm" ? "p-1.5" : "p-2",
isBookmarked
? "text-amber-500 hover:text-amber-600"
: "text-gray-400 hover:text-amber-500 dark:text-gray-500",
)}
aria-label={isBookmarked ? "Убрать из закладок" : "Добавить в закладки"}
>
<svg
className={clsx(size === "sm" ? "h-5 w-5" : "h-6 w-6")}
fill={isBookmarked ? "currentColor" : "none"}
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,21 @@
import { clsx } from "clsx";
import type { Difficulty } from "@/types/method";
interface DifficultyBadgeProps {
difficulty: Difficulty;
}
const config = {
easy: { label: "Легко", class: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300" },
medium: { label: "Средне", class: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300" },
hard: { label: "Сложно", class: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" },
} as const;
export function DifficultyBadge({ difficulty }: DifficultyBadgeProps) {
const { label, class: className } = config[difficulty];
return (
<span className={clsx("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", className)}>
{label}
</span>
);
}

View File

@@ -0,0 +1,35 @@
import { clsx } from "clsx";
import type { Effectiveness } from "@/types/method";
interface EffectivenessIndicatorProps {
effectiveness: Effectiveness;
}
const levels = {
low: { label: "Низкая", filled: 1 },
medium: { label: "Средняя", filled: 2 },
high: { label: "Высокая", filled: 3 },
} as const;
export function EffectivenessIndicator({ effectiveness }: EffectivenessIndicatorProps) {
const { label, filled } = levels[effectiveness];
return (
<div className="flex items-center gap-1.5" title={`Эффективность: ${label}`}>
<span className="text-xs text-gray-500 dark:text-gray-400">Эффективность:</span>
<div className="flex gap-0.5">
{[1, 2, 3].map((level) => (
<div
key={level}
className={clsx(
"h-2.5 w-5 rounded-sm",
level <= filled
? "bg-primary-500"
: "bg-gray-200 dark:bg-gray-700",
)}
/>
))}
</div>
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">{label}</span>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { clsx } from "clsx";
import { CATEGORIES, DIFFICULTIES, EFFECTIVENESS_LEVELS } from "@/types/method";
import type { CategoryId, Difficulty, Effectiveness } from "@/types/method";
interface FilterPanelProps {
selectedCategories: CategoryId[];
selectedDifficulties: Difficulty[];
selectedEffectiveness: Effectiveness[];
onToggleCategory: (id: CategoryId) => void;
onToggleDifficulty: (id: Difficulty) => void;
onToggleEffectiveness: (id: Effectiveness) => void;
onReset: () => void;
}
export function FilterPanel({
selectedCategories,
selectedDifficulties,
selectedEffectiveness,
onToggleCategory,
onToggleDifficulty,
onToggleEffectiveness,
onReset,
}: FilterPanelProps) {
const hasFilters =
selectedCategories.length > 0 ||
selectedDifficulties.length > 0 ||
selectedEffectiveness.length > 0;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Фильтры</h3>
{hasFilters && (
<button
onClick={onReset}
className="text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400"
>
Сбросить
</button>
)}
</div>
{/* Categories */}
<div>
<p className="mb-2 text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Категория</p>
<div className="flex flex-wrap gap-2">
{Object.values(CATEGORIES).map((cat) => (
<button
key={cat.id}
onClick={() => onToggleCategory(cat.id as CategoryId)}
className={clsx(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
selectedCategories.includes(cat.id as CategoryId)
? "bg-primary-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700",
)}
>
{cat.icon} {cat.label}
</button>
))}
</div>
</div>
{/* Difficulty */}
<div>
<p className="mb-2 text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Сложность</p>
<div className="flex flex-wrap gap-2">
{Object.values(DIFFICULTIES).map((diff) => (
<button
key={diff.id}
onClick={() => onToggleDifficulty(diff.id as Difficulty)}
className={clsx(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
selectedDifficulties.includes(diff.id as Difficulty)
? "bg-primary-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700",
)}
>
{diff.label}
</button>
))}
</div>
</div>
{/* Effectiveness */}
<div>
<p className="mb-2 text-xs font-medium text-gray-500 uppercase dark:text-gray-400">Эффективность</p>
<div className="flex flex-wrap gap-2">
{Object.values(EFFECTIVENESS_LEVELS).map((eff) => (
<button
key={eff.id}
onClick={() => onToggleEffectiveness(eff.id as Effectiveness)}
className={clsx(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
selectedEffectiveness.includes(eff.id as Effectiveness)
? "bg-primary-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700",
)}
>
{eff.label}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { Link } from "react-router-dom";
import { clsx } from "clsx";
import type { DietMethod } from "@/types/method";
import { CATEGORIES } from "@/types/method";
import { Badge } from "@/components/Badge";
import { DifficultyBadge } from "@/components/DifficultyBadge";
import { EffectivenessIndicator } from "@/components/EffectivenessIndicator";
import { BookmarkButton } from "@/components/BookmarkButton";
interface MethodCardProps {
method: DietMethod;
isBookmarked: boolean;
onToggleBookmark: (id: string) => void;
}
export function MethodCard({ method, isBookmarked, onToggleBookmark }: MethodCardProps) {
const category = CATEGORIES[method.category];
return (
<Link
to={`/methods/${method.slug}`}
className={clsx(
"group relative flex flex-col rounded-2xl border border-gray-200 bg-white p-6 transition-all",
"hover:border-primary-300 hover:shadow-lg hover:shadow-primary-500/5",
"dark:border-gray-800 dark:bg-gray-900 dark:hover:border-primary-700",
)}
>
<div className="absolute right-4 top-4">
<BookmarkButton
isBookmarked={isBookmarked}
onClick={() => onToggleBookmark(method.id)}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl" aria-hidden="true">{category.icon}</span>
<Badge variant={category.color}>{category.label}</Badge>
<DifficultyBadge difficulty={method.difficulty} />
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900 group-hover:text-primary-600 dark:text-white dark:group-hover:text-primary-400">
{method.title}
</h3>
<p className="mt-2 flex-1 text-sm text-gray-500 dark:text-gray-400">
{method.shortDescription}
</p>
<div className="mt-4">
<EffectivenessIndicator effectiveness={method.effectiveness} />
</div>
<div className="mt-3 text-xs text-gray-400 dark:text-gray-500">
{method.timeframe}
</div>
</Link>
);
}

View File

@@ -0,0 +1,32 @@
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchBar({ value, onChange, placeholder = "Поиск методик..." }: SearchBarProps) {
return (
<div className="relative">
<svg
className="absolute left-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-gray-300 bg-white py-3 pl-11 pr-4 text-sm text-gray-900 outline-none transition-colors placeholder:text-gray-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500 dark:focus:border-primary-400"
/>
</div>
);
}

528
src/data/methods.ts Normal file
View File

@@ -0,0 +1,528 @@
import type { DietMethod } from "@/types/method";
export const methods: DietMethod[] = [
// === ДИЕТЫ ===
{
id: "1",
slug: "intermittent-fasting",
title: "Интервальное голодание",
shortDescription: "Чередование периодов приёма пищи и голодания (16/8, 5:2 и др.)",
fullDescription:
"Интервальное голодание — это режим питания, при котором вы чередуете окна приёма пищи с периодами голодания. Самая популярная схема — 16/8: вы едите в течение 8 часов и голодаете 16 часов. Другой вариант — 5:2, при котором 5 дней вы питаетесь нормально, а 2 дня ограничиваете калории до 500600. Метод не предписывает конкретные продукты, а фокусируется на времени приёма пищи. Исследования показывают положительное влияние на метаболизм, чувствительность к инсулину и аутофагию.",
category: "diet",
difficulty: "medium",
effectiveness: "high",
timeframe: "Результаты через 24 недели",
pros: [
"Не требует подсчёта калорий",
"Гибкость в выборе продуктов",
"Улучшает чувствительность к инсулину",
"Стимулирует аутофагию (клеточное обновление)",
"Бесплатно — не нужны добавки или спецпродукты",
],
cons: [
"Первые дни могут быть тяжёлыми из-за голода",
"Не подходит людям с расстройствами пищевого поведения",
"Может вызвать переедание в окно питания",
"Не рекомендуется беременным и кормящим",
],
scientificBasis:
"Мета-анализ 2020 г. (New England Journal of Medicine) подтвердил эффективность для снижения веса и улучшения метаболических маркеров. Исследования на людях показывают снижение веса на 38% за 324 недели.",
contraindications: [
"Сахарный диабет 1 типа",
"Расстройства пищевого поведения",
"Беременность и кормление грудью",
"Дефицит массы тела",
],
samplePlan:
"Схема 16/8: завтрак в 12:00, обед в 15:00, ужин до 20:00. Между приёмами пищи — вода, чай, кофе без сахара.",
tags: ["голодание", "метаболизм", "инсулин", "аутофагия"],
},
{
id: "2",
slug: "mediterranean-diet",
title: "Средиземноморская диета",
shortDescription: "Питание на основе овощей, фруктов, оливкового масла и рыбы",
fullDescription:
"Средиземноморская диета основана на традиционном питании жителей Греции, Италии и Испании. Основу составляют овощи, фрукты, цельные злаки, бобовые, орехи, оливковое масло и рыба. Красное мясо и сладости ограничены. Это не столько диета для похудения, сколько долгосрочный здоровый стиль питания, который снижает риск сердечно-сосудистых заболеваний, диабета 2 типа и некоторых видов рака.",
category: "diet",
difficulty: "easy",
effectiveness: "high",
timeframe: "Постепенное снижение веса, устойчивый результат за 36 месяцев",
pros: [
"Научно доказанная польза для сердца",
"Разнообразное и вкусное питание",
"Не требует строгих ограничений",
"Снижает риск хронических заболеваний",
"Подходит для длительного применения",
],
cons: [
"Относительно дорогие продукты (рыба, оливковое масло, орехи)",
"Медленное снижение веса",
"Требует навыков приготовления пищи",
],
scientificBasis:
"Исследование PREDIMED (2013, NEJM) с 7447 участниками показало снижение сердечно-сосудистых рисков на 30%. Признана лучшей диетой по рейтингу U.S. News & World Report несколько лет подряд.",
contraindications: [
"Аллергия на морепродукты или орехи (можно адаптировать)",
],
samplePlan:
"Завтрак: овсянка с ягодами и орехами. Обед: салат с тунцом и оливковым маслом. Ужин: запечённая рыба с овощами гриль.",
tags: ["средиземноморье", "сердце", "оливковое масло", "рыба"],
},
{
id: "3",
slug: "keto-diet",
title: "Кетогенная диета (кето)",
shortDescription: "Высокожировая, низкоуглеводная диета для введения организма в кетоз",
fullDescription:
"Кетогенная диета предполагает резкое сокращение углеводов (до 2050 г в день) и увеличение потребления жиров. Когда организм исчерпывает запасы гликогена, он переходит в состояние кетоза — начинает использовать жиры как основной источник энергии, расщепляя их до кетоновых тел. Типичное соотношение макронутриентов: 7080% жиров, 1520% белков, 510% углеводов.",
category: "diet",
difficulty: "hard",
effectiveness: "high",
timeframe: "Быстрое снижение веса в первые 2 недели, устойчивое — за 13 месяца",
pros: [
"Быстрое снижение веса на начальном этапе",
"Снижение аппетита за счёт кетоза",
"Стабильный уровень сахара в крови",
"Эффективна при эпилепсии и некоторых неврологических заболеваниях",
],
cons: [
"Трудно соблюдать длительное время",
"«Кето-грипп» в первые дни адаптации",
"Ограничение фруктов и многих овощей",
"Потенциальные проблемы с холестерином",
"Социальные трудности (ограниченный выбор в ресторанах)",
],
scientificBasis:
"Мета-анализ 13 РКИ (British Journal of Nutrition, 2013) показал, что кетогенная диета приводит к большему снижению веса, чем низкожировая диета в долгосрочной перспективе.",
contraindications: [
"Заболевания печени и поджелудочной железы",
"Дефицит карнитина",
"Порфирия",
"Беременность",
],
samplePlan:
"Завтрак: яичница с авокадо и беконом. Обед: салат с курицей и оливковым маслом. Ужин: стейк из лосося с брокколи в сливочном соусе.",
tags: ["кетоз", "низкоуглеводная", "жиры", "LCHF"],
},
{
id: "4",
slug: "calorie-deficit",
title: "Дефицит калорий (CICO)",
shortDescription: "Контроль калорийности: потреблять меньше, чем расходуешь",
fullDescription:
"CICO (Calories In, Calories Out) — это фундаментальный принцип снижения веса. Вы создаёте дефицит калорий, потребляя меньше энергии, чем тратите. Рекомендуемый дефицит — 300500 ккал в день для безопасного снижения веса 0.51 кг в неделю. Метод не ограничивает продукты, но требует подсчёта калорий и понимания энергетической ценности пищи.",
category: "diet",
difficulty: "medium",
effectiveness: "high",
timeframe: "0.51 кг в неделю при дефиците 500 ккал/день",
pros: [
"Научно доказанный принцип — работает всегда",
"Гибкость в выборе продуктов",
"Подходит для любого бюджета",
"Обучает пониманию калорийности продуктов",
],
cons: [
"Требует постоянного подсчёта калорий",
"Может игнорировать качество питания",
"Утомительно в долгосрочной перспективе",
"Не учитывает гормональный фон",
],
scientificBasis:
"Закон термодинамики подтверждён тысячами исследований. Мета-анализ в American Journal of Clinical Nutrition показывает, что дефицит калорий — единственный обязательный фактор для снижения веса.",
contraindications: [
"Расстройства пищевого поведения",
"Дефицит массы тела",
],
samplePlan:
"Рассчитайте свою норму калорий (TDEE), вычтите 400500 ккал. Используйте приложение для отслеживания (MyFitnessPal, FatSecret).",
tags: ["калории", "подсчёт", "CICO", "дефицит"],
},
{
id: "5",
slug: "plant-based",
title: "Растительное питание",
shortDescription: "Диета на основе растительной пищи с минимумом продуктов животного происхождения",
fullDescription:
"Растительное питание (plant-based diet) делает акцент на овощах, фруктах, бобовых, цельных злаках, орехах и семенах. В отличие от строгого веганства, допускает небольшое количество продуктов животного происхождения. Такой подход естественным образом снижает калорийность за счёт высокого содержания клетчатки и воды в растительных продуктах.",
category: "diet",
difficulty: "medium",
effectiveness: "medium",
timeframe: "Устойчивое снижение за 26 месяцев",
pros: [
"Высокое содержание клетчатки и витаминов",
"Снижение риска сердечно-сосудистых заболеваний",
"Экологически более устойчивый подход",
"Естественное снижение калорийности",
],
cons: [
"Риск дефицита B12, железа, цинка",
"Требует планирования для получения полного белка",
"Может быть непривычным для любителей мяса",
],
scientificBasis:
"Исследование в Journal of the American Heart Association (2019) показало снижение риска сердечно-сосудистых заболеваний на 16% у приверженцев растительного питания.",
contraindications: [
"Тяжёлая анемия (без контроля врача)",
"Детский возраст (без контроля педиатра)",
],
tags: ["растительная", "вегетарианская", "клетчатка", "овощи"],
},
// === ФИЗИЧЕСКАЯ АКТИВНОСТЬ ===
{
id: "6",
slug: "hiit-training",
title: "HIIT (Высокоинтенсивные интервальные тренировки)",
shortDescription: "Короткие взрывные упражнения с периодами отдыха",
fullDescription:
"HIIT (High-Intensity Interval Training) — метод тренировок, чередующий короткие периоды максимальной нагрузки (2060 секунд) с периодами отдыха или низкой интенсивности. Тренировка обычно длится 1530 минут, но по эффективности сжигания калорий сопоставима с 4560 минутами кардио. Ключевой эффект — EPOC (избыточное потребление кислорода после тренировки), которое повышает метаболизм на несколько часов после занятия.",
category: "activity",
difficulty: "hard",
effectiveness: "high",
timeframe: "Видимые результаты через 34 недели при 3 тренировках в неделю",
pros: [
"Эффективное сжигание калорий за короткое время",
"Повышение метаболизма на часы после тренировки (EPOC)",
"Не требует оборудования",
"Сохраняет мышечную массу при похудении",
],
cons: [
"Высокий риск травм при неправильной технике",
"Не подходит начинающим без подготовки",
"Тяжело психологически",
"Требует полноценного восстановления между тренировками",
],
scientificBasis:
"Мета-анализ в British Journal of Sports Medicine (2019) показал, что HIIT на 28.5% эффективнее традиционного кардио для снижения жировой массы.",
contraindications: [
"Сердечно-сосудистые заболевания",
"Проблемы с суставами",
"Нулевая физическая подготовка (сначала базовые тренировки)",
],
samplePlan:
"Табата 4 мин: 20 сек — берпи, 10 сек — отдых, 8 раундов. Повторить 4 раза с отдыхом 1 мин между блоками.",
tags: ["интервальные", "кардио", "жиросжигание", "EPOC"],
},
{
id: "7",
slug: "walking-10k",
title: "Ходьба 10 000 шагов",
shortDescription: "Ежедневная ходьба как простой и безопасный способ увеличить расход калорий",
fullDescription:
"Ходьба — самая доступная и безопасная форма физической активности. 10 000 шагов в день (примерно 78 км) позволяют сжигать 300500 дополнительных калорий. Ходьба не создаёт нагрузки на суставы, не требует специальной подготовки и оборудования, и легко вписывается в повседневную жизнь.",
category: "activity",
difficulty: "easy",
effectiveness: "medium",
timeframe: "Постепенное снижение веса за 23 месяца",
pros: [
"Подходит всем возрастам и уровням подготовки",
"Минимальный риск травм",
"Улучшает настроение и снижает стресс",
"Не требует спортзала и оборудования",
"Легко сделать привычкой",
],
cons: [
"Медленное снижение веса",
"Может быть скучно",
"Зависимость от погоды (для прогулок на улице)",
"Без диеты эффект ограничен",
],
scientificBasis:
"Исследование в JAMA Internal Medicine (2019) с 16 741 участниками показало, что увеличение количества шагов до 7 500 в день снижает смертность на 40%.",
contraindications: [
"Тяжёлые заболевания опорно-двигательного аппарата (консультация врача)",
],
samplePlan:
"Начните с 5 000 шагов и увеличивайте на 500 шагов каждую неделю. Используйте шагомер или фитнес-браслет.",
tags: ["ходьба", "шаги", "кардио", "повседневная"],
},
{
id: "8",
slug: "strength-training",
title: "Силовые тренировки",
shortDescription: "Упражнения с отягощениями для роста мышц и ускорения метаболизма",
fullDescription:
"Силовые тренировки (с гантелями, штангой, тренажёрами или собственным весом) увеличивают мышечную массу, что повышает базовый метаболизм. Каждый килограмм мышц сжигает примерно 13 ккал в сутки в покое (против 4.5 ккал для жира). Помимо похудения, силовые тренировки укрепляют кости, суставы, улучшают осанку и функциональную силу.",
category: "activity",
difficulty: "medium",
effectiveness: "high",
timeframe: "Изменение состава тела заметно через 68 недель",
pros: [
"Ускоряет базовый метаболизм",
"Формирует подтянутую фигуру",
"Укрепляет кости (профилактика остеопороза)",
"Эффект сохраняется даже в покое",
"Улучшает осанку и функциональность",
],
cons: [
"Требует обучения технике",
"Нужен доступ к оборудованию или спортзалу",
"Вес на весах может не снижаться (рост мышц)",
"Риск травм при неправильной технике",
],
scientificBasis:
"Мета-анализ в Obesity Reviews (2021) показал, что силовые тренировки значимо снижают процент жира в теле, даже без изменения диеты.",
contraindications: [
"Острые травмы опорно-двигательного аппарата",
"Нестабильная гипертония",
"Недавно перенесённые операции",
],
samplePlan:
"3 тренировки в неделю: понедельник — верх тела, среда — низ тела, пятница — всё тело. 34 подхода по 812 повторений.",
tags: ["мышцы", "тренажёрный зал", "метаболизм", "сила"],
},
// === ОБРАЗ ЖИЗНИ ===
{
id: "9",
slug: "sleep-optimization",
title: "Оптимизация сна",
shortDescription: "Качественный сон 79 часов как основа здорового метаболизма",
fullDescription:
"Недосыпание (менее 7 часов) повышает уровень грелина (гормон голода) и снижает лептин (гормон сытости), что приводит к перееданию. Кроме того, недостаток сна повышает кортизол и снижает чувствительность к инсулину. Оптимизация сна — это один из самых недооценённых инструментов для контроля веса.",
category: "lifestyle",
difficulty: "easy",
effectiveness: "medium",
timeframe: "Улучшение метаболизма за 12 недели нормализации сна",
pros: [
"Бесплатно",
"Улучшает не только вес, но и общее здоровье",
"Снижает тягу к сладкому и жирному",
"Повышает энергию для тренировок",
],
cons: [
"Может быть сложно при загруженном графике",
"Требует дисциплины и изменения привычек",
"Результат не мгновенный",
],
scientificBasis:
"Исследование в Annals of Internal Medicine (2010) показало, что при недосыпании 55% потери веса приходится на мышцы вместо жира.",
contraindications: [
"Расстройства сна требуют консультации врача (апноэ, бессонница)",
],
samplePlan:
"Ложитесь и вставайте в одно время. За 1 час до сна: нет экранов, нет кофеина после 14:00, прохладная комната (1820°C).",
tags: ["сон", "восстановление", "гормоны", "грелин"],
},
{
id: "10",
slug: "mindful-eating",
title: "Осознанное питание",
shortDescription: "Практика внимательного отношения к еде: есть медленно, чувствовать сигналы тела",
fullDescription:
"Осознанное питание (mindful eating) — это практика полного присутствия во время еды. Вы едите медленно, обращаете внимание на вкус, текстуру, запах пищи, и учитесь различать физический голод от эмоционального. Метод помогает естественным образом уменьшить порции без ощущения ограничения.",
category: "lifestyle",
difficulty: "easy",
effectiveness: "medium",
timeframe: "Формирование привычки за 34 недели, снижение веса за 23 месяца",
pros: [
"Не требует ограничений в продуктах",
"Улучшает отношения с едой",
"Снижает эмоциональное переедание",
"Помогает наслаждаться едой больше",
],
cons: [
"Требует постоянной практики",
"Медленный эффект на вес",
"Трудно практиковать в спешке",
],
scientificBasis:
"Систематический обзор в Obesity Reviews (2014) показал, что практики осознанного питания снижают частоту переедания и эмоционального еды.",
contraindications: [],
samplePlan:
"Ешьте без телефона и ТВ. Жуйте каждый кусочек 2030 раз. Делайте паузу в середине приёма пищи, чтобы оценить голод.",
tags: ["осознанность", "медитация", "порции", "привычки"],
},
{
id: "11",
slug: "water-intake",
title: "Водный режим",
shortDescription: "Достаточное потребление воды для поддержания метаболизма и контроля аппетита",
fullDescription:
"Вода участвует во всех метаболических процессах. Стакан воды перед едой снижает количество потребляемых калорий на 7590 ккал за приём пищи. Часто жажда маскируется под голод, и достаточное потребление воды (30 мл на кг веса) помогает избежать лишних перекусов. Холодная вода дополнительно сжигает калории на нагрев.",
category: "lifestyle",
difficulty: "easy",
effectiveness: "low",
timeframe: "Вспомогательный метод, усиливает другие подходы",
pros: [
"Максимально просто и доступно",
"Помогает отличить голод от жажды",
"Улучшает состояние кожи и пищеварение",
"Усиливает эффект других методов",
],
cons: [
"Сам по себе не приведёт к значительному похудению",
"Избыток воды может быть вреден",
"Частые походы в туалет",
],
scientificBasis:
"Исследование в Obesity (2010): 500 мл воды перед каждым приёмом пищи помогло участникам похудеть на 44% больше за 12 недель.",
contraindications: [
"Заболевания почек (консультация врача о нормах)",
"Сердечная недостаточность",
],
samplePlan:
"Выпивайте стакан воды сразу после пробуждения. Пейте 500 мл за 30 мин до каждого приёма пищи. Норма: 30 мл × вес тела в кг.",
tags: ["вода", "гидратация", "метаболизм", "аппетит"],
},
// === ПСИХОЛОГИЯ ===
{
id: "12",
slug: "cognitive-behavioral-therapy",
title: "Когнитивно-поведенческая терапия (КПТ)",
shortDescription: "Работа с пищевыми привычками и мышлением через психотерапию",
fullDescription:
"КПТ для управления весом фокусируется на выявлении и изменении деструктивных мыслей и поведенческих паттернов, связанных с едой. Терапевт помогает осознать триггеры переедания, изменить отношение к еде и телу, сформировать устойчивые здоровые привычки. Это один из самых эффективных подходов для долгосрочного поддержания результата.",
category: "psychology",
difficulty: "medium",
effectiveness: "high",
timeframe: "1220 сеансов, устойчивый эффект до нескольких лет",
pros: [
"Работает с корневыми причинами переедания",
"Долгосрочный устойчивый результат",
"Улучшает общее психическое здоровье",
"Научно доказанная эффективность",
],
cons: [
"Требует работы со специалистом",
"Стоимость терапии",
"Требует времени и усилий",
"Не даёт быстрого результата на весах",
],
scientificBasis:
"Кокрановский обзор (2005, обновлён 2018) подтверждает эффективность КПТ для лечения переедания и поддержания здорового веса в долгосрочной перспективе.",
contraindications: [],
samplePlan:
"Найдите психолога, специализирующегося на КПТ и пищевом поведении. Курс обычно 1220 еженедельных сеансов.",
tags: ["психология", "терапия", "привычки", "переедание"],
},
{
id: "13",
slug: "habit-stacking",
title: "Наслаивание привычек (Habit Stacking)",
shortDescription: "Привязка новых здоровых привычек к уже существующим",
fullDescription:
"Метод наслаивания привычек основан на нейробиологии формирования привычек. Вы привязываете новое желаемое действие к уже существующей привычке: «После того как я [существующая привычка], я сделаю [новая привычка]». Например: «После того как я налью утренний кофе, я сделаю 10 приседаний». Этот метод использует уже сформированные нейронные пути, что значительно облегчает формирование новых привычек.",
category: "psychology",
difficulty: "easy",
effectiveness: "medium",
timeframe: "Формирование устойчивой привычки за 2166 дней",
pros: [
"Очень просто начать",
"Использует уже существующие привычки",
"Постепенное наращивание без стресса",
"Научное обоснование (нейропластичность)",
],
cons: [
"Медленное накопление эффекта",
"Требует последовательности",
"Не даёт быстрого результата",
],
scientificBasis:
"Основан на исследованиях нейропластичности и работах BJ Fogg (Stanford). Исследование в European Journal of Social Psychology (2009) показало, что среднее время формирования привычки — 66 дней.",
contraindications: [],
samplePlan:
"Составьте список из 3 привычек: 1) После пробуждения — стакан воды. 2) После обеда — 10-минутная прогулка. 3) После ужина — 5 минут растяжки.",
tags: ["привычки", "мотивация", "психология", "постепенность"],
},
// === МЕДИЦИНСКИЕ ===
{
id: "14",
slug: "medical-supervision",
title: "Похудение под наблюдением врача",
shortDescription: "Программа снижения веса с медицинским контролем и анализами",
fullDescription:
"Медицинская программа снижения веса включает: обследование (анализы крови, оценка гормонов, УЗИ), составление индивидуального плана питания врачом-диетологом, мониторинг прогресса. Может включать фармакотерапию (например, препараты GLP-1: семаглутид, лираглутид). Подход рекомендуется при ожирении (ИМТ > 30) или при наличии сопутствующих заболеваний.",
category: "medical",
difficulty: "medium",
effectiveness: "high",
timeframe: "Индивидуально, обычно программа на 312 месяцев",
pros: [
"Индивидуальный подход",
"Контроль здоровья на каждом этапе",
"Выявление скрытых причин лишнего веса",
"Безопасность при наличии хронических заболеваний",
"Возможность медикаментозной поддержки",
],
cons: [
"Высокая стоимость",
"Требует регулярных визитов к врачу",
"Зависимость от препаратов (при фармакотерапии)",
],
scientificBasis:
"Рандомизированные исследования STEP (2021) показали снижение веса до 15% на семаглутиде за 68 недель. Медицинский контроль снижает риски в 35 раз по сравнению с самостоятельным похудением при ожирении.",
contraindications: [
"Зависит от выбранных препаратов и методов — определяет врач",
],
samplePlan:
"Запишитесь к эндокринологу. Сдайте: общий анализ крови, ТТГ, инсулин, HbA1c, липидный профиль. Обсудите индивидуальный план.",
tags: ["врач", "эндокринолог", "анализы", "семаглутид"],
},
{
id: "15",
slug: "elimination-diet",
title: "Элиминационная диета",
shortDescription: "Исключение потенциальных пищевых аллергенов для выявления проблемных продуктов",
fullDescription:
"Элиминационная диета — это диагностический инструмент, при котором из рациона на 24 недели исключаются потенциально проблемные продукты (глютен, молочные продукты, яйца, соя, орехи, сахар). Затем продукты по одному возвращаются, с отслеживанием реакции организма. Хроническое воспаление от пищевой непереносимости может замедлять метаболизм и способствовать задержке жидкости.",
category: "medical",
difficulty: "hard",
effectiveness: "medium",
timeframe: "Диагностический период 68 недель, далее индивидуально",
pros: [
"Помогает выявить скрытые пищевые непереносимости",
"Уменьшает воспаление и отёки",
"Улучшает пищеварение и самочувствие",
"Персонализированный подход к питанию",
],
cons: [
"Очень ограничительная на начальном этапе",
"Требует дисциплины и ведения дневника",
"Социально затруднительна",
"Риск дефицита нутриентов без контроля",
],
scientificBasis:
"NICE guidelines рекомендуют элиминационные диеты для диагностики пищевой непереносимости. Исследования показывают, что до 20% населения имеют ту или иную пищевую непереносимость.",
contraindications: [
"Расстройства пищевого поведения",
"Дефицит массы тела",
"Дети без контроля педиатра",
],
samplePlan:
"Фаза 1 (24 недели): исключите глютен, молочные, сахар, яйца, сою. Фаза 2: возвращайте по одному продукту каждые 3 дня, записывая реакции.",
tags: ["аллергия", "воспаление", "глютен", "непереносимость"],
},
{
id: "16",
slug: "stress-management",
title: "Управление стрессом",
shortDescription: "Техники снижения стресса для нормализации кортизола и пищевого поведения",
fullDescription:
"Хронический стресс повышает уровень кортизола — гормона, который стимулирует отложение жира в области живота и усиливает тягу к калорийной пище. Методы управления стрессом (медитация, дыхательные упражнения, йога, прогулки на природе) помогают нормализовать гормональный фон и снизить эмоциональное переедание.",
category: "psychology",
difficulty: "easy",
effectiveness: "medium",
timeframe: "Снижение кортизола за 24 недели регулярной практики",
pros: [
"Улучшает общее качество жизни",
"Снижает кортизол и висцеральный жир",
"Множество бесплатных методов",
"Помогает от эмоционального переедания",
],
cons: [
"Не работает как единственный метод похудения",
"Требует регулярной практики",
"Эффект на вес косвенный",
],
scientificBasis:
"Исследование в Obesity (2017) показало, что программа снижения стресса с медитацией привела к значимому уменьшению висцерального жира без изменения диеты.",
contraindications: [],
samplePlan:
"Утро: 10 минут медитации (приложение Headspace/Calm). Обед: 5 минут дыхания 4-7-8. Вечер: 15 минут прогулки.",
tags: ["стресс", "кортизол", "медитация", "йога"],
},
];

22
src/hooks/useBookmarks.ts Normal file
View File

@@ -0,0 +1,22 @@
import { useCallback } from "react";
import { useLocalStorage } from "@/hooks/useLocalStorage";
export function useBookmarks() {
const [bookmarks, setBookmarks] = useLocalStorage<string[]>("bookmarks", []);
const toggleBookmark = useCallback(
(id: string) => {
setBookmarks((prev) =>
prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id],
);
},
[setBookmarks],
);
const isBookmarked = useCallback(
(id: string) => bookmarks.includes(id),
[bookmarks],
);
return { bookmarks, toggleBookmark, isBookmarked };
}

View File

@@ -0,0 +1,25 @@
import { useState, useCallback } from "react";
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
setStoredValue((prev) => {
const valueToStore = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(valueToStore));
return valueToStore;
});
},
[key],
);
return [storedValue, setValue] as const;
}

View File

@@ -0,0 +1,149 @@
import { useState } from "react";
import { clsx } from "clsx";
import { calculateBmi, type BmiResult } from "@/utils/bmi";
export function BmiCalculatorPage() {
const [weight, setWeight] = useState("");
const [height, setHeight] = useState("");
const [result, setResult] = useState<BmiResult | null>(null);
const handleCalculate = (e: React.FormEvent) => {
e.preventDefault();
const w = parseFloat(weight);
const h = parseFloat(height);
if (w > 0 && h > 0) {
setResult(calculateBmi(w, h));
}
};
const handleReset = () => {
setWeight("");
setHeight("");
setResult(null);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mx-auto max-w-2xl">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Калькулятор BMI
</h1>
<p className="mt-2 text-gray-500 dark:text-gray-400">
Индекс массы тела (BMI) показатель соотношения веса и роста. Помогает
оценить, находится ли ваш вес в здоровом диапазоне.
</p>
<form onSubmit={handleCalculate} className="mt-8 space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<div>
<label
htmlFor="weight"
className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Вес (кг)
</label>
<input
id="weight"
type="number"
min="20"
max="300"
step="0.1"
value={weight}
onChange={(e) => setWeight(e.target.value)}
placeholder="70"
required
className="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 outline-none transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</div>
<div>
<label
htmlFor="height"
className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Рост (см)
</label>
<input
id="height"
type="number"
min="100"
max="250"
step="0.1"
value={height}
onChange={(e) => setHeight(e.target.value)}
placeholder="175"
required
className="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 outline-none transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
/>
</div>
</div>
<div className="flex gap-3">
<button
type="submit"
className="rounded-xl bg-primary-600 px-8 py-3 text-sm font-semibold text-white shadow-lg shadow-primary-500/25 transition hover:bg-primary-700"
>
Рассчитать
</button>
{result && (
<button
type="button"
onClick={handleReset}
className="rounded-xl border border-gray-300 px-6 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
>
Сбросить
</button>
)}
</div>
</form>
{/* Result */}
{result && (
<div className="mt-8 rounded-2xl border border-gray-200 bg-white p-8 dark:border-gray-800 dark:bg-gray-900">
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">Ваш BMI</p>
<p className={clsx("mt-1 text-5xl font-extrabold", result.color)}>
{result.value}
</p>
<p className={clsx("mt-2 text-lg font-semibold", result.color)}>
{result.category}
</p>
</div>
<div className="mt-6 rounded-xl bg-gray-50 p-4 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{result.recommendation}
</div>
{/* BMI Scale */}
<div className="mt-6">
<p className="mb-3 text-xs font-medium text-gray-500 uppercase dark:text-gray-400">
Шкала BMI
</p>
<div className="flex h-3 overflow-hidden rounded-full">
<div className="w-[18.5%] bg-blue-400" title="Дефицит (<18.5)" />
<div className="w-[25%] bg-green-400" title="Норма (18.525)" />
<div className="w-[20%] bg-yellow-400" title="Избыток (2530)" />
<div className="w-[20%] bg-orange-400" title="Ожирение I (3035)" />
<div className="w-[16.5%] bg-red-400" title="Ожирение IIIII (35+)" />
</div>
<div className="mt-1 flex justify-between text-xs text-gray-400">
<span>16</span>
<span>18.5</span>
<span>25</span>
<span>30</span>
<span>35</span>
<span>40+</span>
</div>
</div>
</div>
)}
{/* Info */}
<div className="mt-8 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
<strong>Важно:</strong> BMI приблизительный показатель. Он не учитывает
мышечную массу, возраст, пол и распределение жира. Для полной оценки
здоровья обратитесь к врачу.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { useParams, Link } from "react-router-dom";
import { methods } from "@/data/methods";
import { CATEGORIES } from "@/types/method";
import { useBookmarks } from "@/hooks/useBookmarks";
import { Badge } from "@/components/Badge";
import { DifficultyBadge } from "@/components/DifficultyBadge";
import { EffectivenessIndicator } from "@/components/EffectivenessIndicator";
import { BookmarkButton } from "@/components/BookmarkButton";
export function MethodDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { isBookmarked, toggleBookmark } = useBookmarks();
const method = methods.find((m) => m.slug === slug);
if (!method) {
return (
<div className="container mx-auto flex flex-col items-center justify-center px-4 py-20">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Методика не найдена</h1>
<Link to="/methods" className="mt-4 text-primary-600 hover:underline dark:text-primary-400">
Вернуться к списку
</Link>
</div>
);
}
const category = CATEGORIES[method.category];
return (
<div className="container mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="mb-6 text-sm text-gray-500 dark:text-gray-400">
<Link to="/" className="hover:text-primary-600">Главная</Link>
<span className="mx-2">/</span>
<Link to="/methods" className="hover:text-primary-600">Методики</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 dark:text-white">{method.title}</span>
</nav>
<article className="mx-auto max-w-4xl">
{/* Header */}
<header className="mb-8">
<div className="flex items-center gap-3">
<span className="text-3xl" aria-hidden="true">{category.icon}</span>
<Badge variant={category.color} size="md">{category.label}</Badge>
<DifficultyBadge difficulty={method.difficulty} />
<BookmarkButton
isBookmarked={isBookmarked(method.id)}
onClick={() => toggleBookmark(method.id)}
size="md"
/>
</div>
<h1 className="mt-4 text-3xl font-extrabold text-gray-900 sm:text-4xl dark:text-white">
{method.title}
</h1>
<p className="mt-3 text-lg text-gray-500 dark:text-gray-400">
{method.shortDescription}
</p>
<div className="mt-4 flex flex-wrap items-center gap-4">
<EffectivenessIndicator effectiveness={method.effectiveness} />
<span className="text-sm text-gray-400 dark:text-gray-500">{method.timeframe}</span>
</div>
</header>
{/* Description */}
<Section title="Подробное описание">
<p className="text-gray-700 leading-relaxed dark:text-gray-300">{method.fullDescription}</p>
</Section>
{/* Pros & Cons */}
<div className="grid gap-6 sm:grid-cols-2">
<Section title="Преимущества">
<ul className="space-y-2">
{method.pros.map((pro) => (
<li key={pro} className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
<span className="mt-0.5 text-green-500">&#x2713;</span>
{pro}
</li>
))}
</ul>
</Section>
<Section title="Недостатки">
<ul className="space-y-2">
{method.cons.map((con) => (
<li key={con} className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
<span className="mt-0.5 text-red-500">&#x2717;</span>
{con}
</li>
))}
</ul>
</Section>
</div>
{/* Scientific Basis */}
<Section title="Научное обоснование">
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
{method.scientificBasis}
</div>
</Section>
{/* Contraindications */}
{method.contraindications.length > 0 && (
<Section title="Противопоказания">
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<ul className="space-y-1">
{method.contraindications.map((c) => (
<li key={c} className="flex items-start gap-2 text-sm text-red-700 dark:text-red-300">
<span className="mt-0.5">&#x26A0;</span>
{c}
</li>
))}
</ul>
</div>
</Section>
)}
{/* Sample Plan */}
{method.samplePlan && (
<Section title="Примерный план">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
{method.samplePlan}
</div>
</Section>
)}
{/* Tags */}
<div className="mt-8 flex flex-wrap gap-2">
{method.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
#{tag}
</span>
))}
</div>
{/* Back link */}
<div className="mt-10 border-t border-gray-200 pt-6 dark:border-gray-800">
<Link
to="/methods"
className="inline-flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400"
>
<span aria-hidden="true">&larr;</span>
Все методики
</Link>
</div>
</article>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mb-8">
<h2 className="mb-4 text-xl font-bold text-gray-900 dark:text-white">{title}</h2>
{children}
</section>
);
}

109
src/pages/MethodsPage.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { useState, useMemo } from "react";
import { methods } from "@/data/methods";
import { useBookmarks } from "@/hooks/useBookmarks";
import { MethodCard } from "@/components/MethodCard";
import { SearchBar } from "@/components/SearchBar";
import { FilterPanel } from "@/components/FilterPanel";
import type { CategoryId, Difficulty, Effectiveness } from "@/types/method";
function toggleInArray<T>(arr: T[], item: T): T[] {
return arr.includes(item) ? arr.filter((i) => i !== item) : [...arr, item];
}
export function MethodsPage() {
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<CategoryId[]>([]);
const [selectedDifficulties, setSelectedDifficulties] = useState<Difficulty[]>([]);
const [selectedEffectiveness, setSelectedEffectiveness] = useState<Effectiveness[]>([]);
const [showBookmarkedOnly, setShowBookmarkedOnly] = useState(false);
const { isBookmarked, toggleBookmark } = useBookmarks();
const filtered = useMemo(() => {
const query = search.toLowerCase().trim();
return methods.filter((m) => {
if (query) {
const searchable = [m.title, m.shortDescription, ...m.tags].join(" ").toLowerCase();
if (!searchable.includes(query)) return false;
}
if (selectedCategories.length > 0 && !selectedCategories.includes(m.category)) return false;
if (selectedDifficulties.length > 0 && !selectedDifficulties.includes(m.difficulty)) return false;
if (selectedEffectiveness.length > 0 && !selectedEffectiveness.includes(m.effectiveness)) return false;
if (showBookmarkedOnly && !isBookmarked(m.id)) return false;
return true;
});
}, [search, selectedCategories, selectedDifficulties, selectedEffectiveness, showBookmarkedOnly, isBookmarked]);
const resetFilters = () => {
setSelectedCategories([]);
setSelectedDifficulties([]);
setSelectedEffectiveness([]);
setShowBookmarkedOnly(false);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Методики</h1>
<p className="mt-2 text-gray-500 dark:text-gray-400">
{methods.length} методик похудения и поддержания здорового веса
</p>
</div>
<div className="grid gap-8 lg:grid-cols-[280px_1fr]">
{/* Sidebar */}
<aside className="space-y-6">
<SearchBar value={search} onChange={setSearch} />
<FilterPanel
selectedCategories={selectedCategories}
selectedDifficulties={selectedDifficulties}
selectedEffectiveness={selectedEffectiveness}
onToggleCategory={(id) => setSelectedCategories((prev) => toggleInArray(prev, id))}
onToggleDifficulty={(id) => setSelectedDifficulties((prev) => toggleInArray(prev, id))}
onToggleEffectiveness={(id) => setSelectedEffectiveness((prev) => toggleInArray(prev, id))}
onReset={resetFilters}
/>
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={showBookmarkedOnly}
onChange={(e) => setShowBookmarkedOnly(e.target.checked)}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
Только закладки
</label>
</aside>
{/* Grid */}
<div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-300 py-16 dark:border-gray-700">
<p className="text-lg text-gray-500 dark:text-gray-400">Ничего не найдено</p>
<button
onClick={resetFilters}
className="mt-2 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400"
>
Сбросить фильтры
</button>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((method) => (
<MethodCard
key={method.id}
method={method}
isBookmarked={isBookmarked(method.id)}
onToggleBookmark={toggleBookmark}
/>
))}
</div>
)}
<p className="mt-4 text-sm text-gray-400 dark:text-gray-500">
Показано {filtered.length} из {methods.length}
</p>
</div>
</div>
</div>
);
}

43
src/types/method.ts Normal file
View File

@@ -0,0 +1,43 @@
export interface DietMethod {
id: string;
slug: string;
title: string;
shortDescription: string;
fullDescription: string;
category: CategoryId;
difficulty: Difficulty;
effectiveness: Effectiveness;
timeframe: string;
pros: string[];
cons: string[];
scientificBasis: string;
contraindications: string[];
samplePlan?: string;
tags: string[];
}
export const CATEGORIES = {
diet: { id: "diet", label: "Диеты", icon: "\u{1F957}", color: "emerald" },
activity: { id: "activity", label: "Физическая активность", icon: "\u{1F3CB}\u{FE0F}", color: "blue" },
lifestyle: { id: "lifestyle", label: "Образ жизни", icon: "\u{1F331}", color: "violet" },
psychology: { id: "psychology", label: "Психология", icon: "\u{1F9E0}", color: "amber" },
medical: { id: "medical", label: "Медицинские", icon: "\u{2695}\u{FE0F}", color: "rose" },
} as const;
export type CategoryId = keyof typeof CATEGORIES;
export const DIFFICULTIES = {
easy: { id: "easy", label: "Легко", order: 1 },
medium: { id: "medium", label: "Средне", order: 2 },
hard: { id: "hard", label: "Сложно", order: 3 },
} as const;
export type Difficulty = keyof typeof DIFFICULTIES;
export const EFFECTIVENESS_LEVELS = {
low: { id: "low", label: "Низкая", order: 1 },
medium: { id: "medium", label: "Средняя", order: 2 },
high: { id: "high", label: "Высокая", order: 3 },
} as const;
export type Effectiveness = keyof typeof EFFECTIVENESS_LEVELS;

67
src/utils/bmi.ts Normal file
View File

@@ -0,0 +1,67 @@
export interface BmiResult {
value: number;
category: string;
color: string;
recommendation: string;
}
export function calculateBmi(weightKg: number, heightCm: number): BmiResult {
const heightM = heightCm / 100;
const bmi = weightKg / (heightM * heightM);
const value = Math.round(bmi * 10) / 10;
if (value < 16) {
return {
value,
category: "Выраженный дефицит массы тела",
color: "text-red-600",
recommendation: "Необходимо срочно обратиться к врачу. Недостаточная масса тела опасна для здоровья.",
};
}
if (value < 18.5) {
return {
value,
category: "Дефицит массы тела",
color: "text-orange-500",
recommendation: "Рекомендуется увеличить калорийность рациона и обратиться к диетологу.",
};
}
if (value < 25) {
return {
value,
category: "Нормальная масса тела",
color: "text-green-600",
recommendation: "Отличный показатель! Поддерживайте текущий вес с помощью сбалансированного питания и активности.",
};
}
if (value < 30) {
return {
value,
category: "Избыточная масса тела (предожирение)",
color: "text-yellow-600",
recommendation: "Рекомендуется скорректировать питание и увеличить физическую активность. Ознакомьтесь с методиками в нашем справочнике.",
};
}
if (value < 35) {
return {
value,
category: "Ожирение I степени",
color: "text-orange-600",
recommendation: "Рекомендуется обратиться к врачу и составить план снижения веса. Изучите методики под медицинским контролем.",
};
}
if (value < 40) {
return {
value,
category: "Ожирение II степени",
color: "text-red-500",
recommendation: "Необходимо обратиться к эндокринологу. Снижение веса под медицинским наблюдением.",
};
}
return {
value,
category: "Ожирение III степени (морбидное)",
color: "text-red-700",
recommendation: "Необходима медицинская помощь. Обратитесь к специалисту для составления программы лечения.",
};
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/footer.tsx","./src/components/header.tsx","./src/components/layout.tsx","./src/pages/homepage.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/badge.tsx","./src/components/bookmarkbutton.tsx","./src/components/difficultybadge.tsx","./src/components/effectivenessindicator.tsx","./src/components/filterpanel.tsx","./src/components/footer.tsx","./src/components/header.tsx","./src/components/layout.tsx","./src/components/methodcard.tsx","./src/components/searchbar.tsx","./src/data/methods.ts","./src/hooks/usebookmarks.ts","./src/hooks/uselocalstorage.ts","./src/pages/bmicalculatorpage.tsx","./src/pages/homepage.tsx","./src/pages/methoddetailpage.tsx","./src/pages/methodspage.tsx","./src/types/method.ts","./src/utils/bmi.ts"],"version":"5.9.3"}