feat: add dark mode, mobile menu, method comparison, scroll-to-top

- Add theme toggle (light/dark/system) with localStorage persistence
- Implement responsive mobile hamburger menu
- Create ComparePage for side-by-side comparison of up to 3 methods
- Add ScrollToTop component for route changes
- Update navigation with compare page link

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-11 12:27:42 +03:00
parent b8dd591428
commit 15133ecb52
8 changed files with 298 additions and 20 deletions

View File

@@ -4,6 +4,7 @@ import { HomePage } from "@/pages/HomePage";
import { MethodsPage } from "@/pages/MethodsPage";
import { MethodDetailPage } from "@/pages/MethodDetailPage";
import { BmiCalculatorPage } from "@/pages/BmiCalculatorPage";
import { ComparePage } from "@/pages/ComparePage";
export function App() {
return (
@@ -13,6 +14,7 @@ export function App() {
<Route path="methods" element={<MethodsPage />} />
<Route path="methods/:slug" element={<MethodDetailPage />} />
<Route path="calculator" element={<BmiCalculatorPage />} />
<Route path="compare" element={<ComparePage />} />
</Route>
</Routes>
);

View File

@@ -1,26 +1,37 @@
import { useState } from "react";
import { Link, NavLink } from "react-router-dom";
import { clsx } from "clsx";
import { ThemeToggle } from "@/components/ThemeToggle";
const navLinks = [
{ to: "/", label: "Главная" },
{ to: "/methods", label: "Методики" },
{ to: "/calculator", label: "Калькулятор BMI" },
{ to: "/", label: "Главная", exact: true },
{ to: "/methods", label: "Методики", exact: false },
{ to: "/compare", label: "Сравнение", exact: true },
{ to: "/calculator", label: "Калькулятор BMI", exact: true },
] as const;
export function Header() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-50 border-b border-gray-200 bg-white/80 backdrop-blur-lg dark:border-gray-800 dark:bg-gray-950/80">
<div className="container mx-auto flex items-center justify-between px-4 py-4">
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-primary-600">
<Link
to="/"
className="flex items-center gap-2 text-xl font-bold text-primary-600"
onClick={() => setMobileOpen(false)}
>
<span aria-hidden="true" className="text-2xl">&#x1F33F;</span>
HealthyWeight
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-1 md:flex">
{navLinks.map(({ to, label }) => (
{navLinks.map(({ to, label, exact }) => (
<NavLink
key={to}
to={to}
end={exact}
className={({ isActive }) =>
clsx(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
@@ -33,23 +44,54 @@ export function Header() {
{label}
</NavLink>
))}
<ThemeToggle />
</nav>
<MobileMenuButton />
{/* Mobile controls */}
<div className="flex items-center gap-1 md:hidden">
<ThemeToggle />
<button
onClick={() => setMobileOpen((prev) => !prev)}
className="rounded-lg p-2 text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label={mobileOpen ? "Закрыть меню" : "Открыть меню"}
aria-expanded={mobileOpen}
>
{mobileOpen ? (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
)}
</button>
</div>
</div>
{/* Mobile nav */}
{mobileOpen && (
<nav className="border-t border-gray-200 bg-white px-4 py-3 md:hidden dark:border-gray-800 dark:bg-gray-950">
{navLinks.map(({ to, label, exact }) => (
<NavLink
key={to}
to={to}
end={exact}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
clsx(
"block rounded-lg px-4 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800",
)
}
>
{label}
</NavLink>
))}
</nav>
)}
</header>
);
}
function MobileMenuButton() {
return (
<button
className="rounded-lg p-2 text-gray-600 hover:bg-gray-100 md:hidden dark:text-gray-400 dark:hover:bg-gray-800"
aria-label="Открыть меню"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
);
}

View File

@@ -1,10 +1,12 @@
import { Outlet } from "react-router-dom";
import { Header } from "@/components/Header";
import { Footer } from "@/components/Footer";
import { ScrollToTop } from "@/components/ScrollToTop";
export function Layout() {
return (
<div className="flex min-h-screen flex-col">
<ScrollToTop />
<Header />
<main className="flex-1">
<Outlet />

View File

@@ -0,0 +1,12 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
export function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,40 @@
import { useTheme } from "@/hooks/useTheme";
const icons = {
light: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
),
dark: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
),
system: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25" />
</svg>
),
} as const;
const labels = {
light: "Светлая тема",
dark: "Тёмная тема",
system: "Системная тема",
} as const;
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label={labels[theme]}
title={labels[theme]}
>
{icons[theme]}
</button>
);
}

38
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,38 @@
import { useCallback, useEffect } from "react";
import { useLocalStorage } from "@/hooks/useLocalStorage";
type Theme = "light" | "dark" | "system";
function getSystemTheme(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: Theme) {
const resolved = theme === "system" ? getSystemTheme() : theme;
document.documentElement.classList.toggle("dark", resolved === "dark");
}
export function useTheme() {
const [theme, setTheme] = useLocalStorage<Theme>("theme", "system");
useEffect(() => {
applyTheme(theme);
if (theme === "system") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => applyTheme("system");
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}
}, [theme]);
const toggleTheme = useCallback(() => {
setTheme((prev) => {
if (prev === "light") return "dark";
if (prev === "dark") return "system";
return "light";
});
}, [setTheme]);
return { theme, setTheme, toggleTheme };
}

142
src/pages/ComparePage.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { useState, useMemo } from "react";
import { Link } from "react-router-dom";
import { clsx } from "clsx";
import { methods } from "@/data/methods";
import { CATEGORIES, DIFFICULTIES, EFFECTIVENESS_LEVELS } from "@/types/method";
import type { DietMethod } from "@/types/method";
const MAX_COMPARE = 3;
export function ComparePage() {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const selectedMethods = useMemo(
() => selectedIds.map((id) => methods.find((m) => m.id === id)).filter(Boolean) as DietMethod[],
[selectedIds],
);
const toggleMethod = (id: string) => {
setSelectedIds((prev) => {
if (prev.includes(id)) return prev.filter((i) => i !== id);
if (prev.length >= MAX_COMPARE) return prev;
return [...prev, id];
});
};
return (
<div className="container mx-auto px-4 py-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">
Выберите до {MAX_COMPARE} методик для сравнения
</p>
{/* Selector */}
<div className="mt-6 flex flex-wrap gap-2">
{methods.map((m) => {
const selected = selectedIds.includes(m.id);
const disabled = !selected && selectedIds.length >= MAX_COMPARE;
return (
<button
key={m.id}
onClick={() => toggleMethod(m.id)}
disabled={disabled}
className={clsx(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
selected
? "bg-primary-600 text-white"
: disabled
? "cursor-not-allowed bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-primary-100 hover:text-primary-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-primary-900/30",
)}
>
{CATEGORIES[m.category].icon} {m.title}
</button>
);
})}
</div>
{/* Comparison table */}
{selectedMethods.length >= 2 && (
<div className="mt-8 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="border-b border-gray-200 px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase dark:border-gray-700 dark:text-gray-400">
Параметр
</th>
{selectedMethods.map((m) => (
<th
key={m.id}
className="border-b border-gray-200 px-4 py-3 text-left text-xs font-semibold text-gray-900 uppercase dark:border-gray-700 dark:text-white"
>
<Link to={`/methods/${m.slug}`} className="hover:text-primary-600">
{m.title}
</Link>
</th>
))}
</tr>
</thead>
<tbody>
<CompareRow label="Категория" items={selectedMethods.map((m) => `${CATEGORIES[m.category].icon} ${CATEGORIES[m.category].label}`)} />
<CompareRow label="Сложность" items={selectedMethods.map((m) => DIFFICULTIES[m.difficulty].label)} />
<CompareRow label="Эффективность" items={selectedMethods.map((m) => EFFECTIVENESS_LEVELS[m.effectiveness].label)} />
<CompareRow label="Сроки" items={selectedMethods.map((m) => m.timeframe)} />
<CompareRow
label="Преимущества"
items={selectedMethods.map((m) => (
<ul className="list-inside list-disc space-y-1">
{m.pros.map((p) => <li key={p}>{p}</li>)}
</ul>
))}
/>
<CompareRow
label="Недостатки"
items={selectedMethods.map((m) => (
<ul className="list-inside list-disc space-y-1">
{m.cons.map((c) => <li key={c}>{c}</li>)}
</ul>
))}
/>
<CompareRow label="Научное обоснование" items={selectedMethods.map((m) => m.scientificBasis)} />
<CompareRow
label="Противопоказания"
items={selectedMethods.map((m) =>
m.contraindications.length > 0
? m.contraindications.join(", ")
: "Нет",
)}
/>
</tbody>
</table>
</div>
)}
{selectedMethods.length < 2 && selectedMethods.length > 0 && (
<div className="mt-8 rounded-xl border-2 border-dashed border-gray-300 py-12 text-center text-gray-500 dark:border-gray-700 dark:text-gray-400">
Выберите ещё {2 - selectedMethods.length} методик{selectedMethods.length === 1 ? "у" : "и"} для сравнения
</div>
)}
{selectedMethods.length === 0 && (
<div className="mt-8 rounded-xl border-2 border-dashed border-gray-300 py-12 text-center text-gray-500 dark:border-gray-700 dark:text-gray-400">
Выберите методики из списка выше
</div>
)}
</div>
);
}
function CompareRow({ label, items }: { label: string; items: React.ReactNode[] }) {
return (
<tr className="border-b border-gray-100 dark:border-gray-800">
<td className="px-4 py-3 font-medium text-gray-600 align-top dark:text-gray-400">
{label}
</td>
{items.map((item, i) => (
<td key={i} className="px-4 py-3 text-gray-700 align-top dark:text-gray-300">
{item}
</td>
))}
</tr>
);
}

View File

@@ -1 +1 @@
{"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"}
{"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/scrolltotop.tsx","./src/components/searchbar.tsx","./src/components/themetoggle.tsx","./src/data/methods.ts","./src/hooks/usebookmarks.ts","./src/hooks/uselocalstorage.ts","./src/hooks/usetheme.ts","./src/pages/bmicalculatorpage.tsx","./src/pages/comparepage.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"}