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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">🌿</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
12
src/components/ScrollToTop.tsx
Normal file
12
src/components/ScrollToTop.tsx
Normal 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;
|
||||
}
|
||||
40
src/components/ThemeToggle.tsx
Normal file
40
src/components/ThemeToggle.tsx
Normal 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
38
src/hooks/useTheme.ts
Normal 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
142
src/pages/ComparePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user