diff --git a/src/App.tsx b/src/App.tsx index 216ac75..13fd8ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> ); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a8d66d8..d737a3b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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 (
- + setMobileOpen(false)} + > HealthyWeight + {/* Desktop nav */} - + {/* Mobile controls */} +
+ + +
+ + {/* Mobile nav */} + {mobileOpen && ( + + )}
); } - -function MobileMenuButton() { - return ( - - ); -} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 97a429a..b081aec 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 (
+
diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx new file mode 100644 index 0000000..bf31b9e --- /dev/null +++ b/src/components/ScrollToTop.tsx @@ -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; +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..2bd77ac --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,40 @@ +import { useTheme } from "@/hooks/useTheme"; + +const icons = { + light: ( + + + + ), + dark: ( + + + + ), + system: ( + + + + ), +} as const; + +const labels = { + light: "Светлая тема", + dark: "Тёмная тема", + system: "Системная тема", +} as const; + +export function ThemeToggle() { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..bbacabe --- /dev/null +++ b/src/hooks/useTheme.ts @@ -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", "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 }; +} diff --git a/src/pages/ComparePage.tsx b/src/pages/ComparePage.tsx new file mode 100644 index 0000000..898f611 --- /dev/null +++ b/src/pages/ComparePage.tsx @@ -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([]); + + 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 ( +
+

Сравнение методик

+

+ Выберите до {MAX_COMPARE} методик для сравнения +

+ + {/* Selector */} +
+ {methods.map((m) => { + const selected = selectedIds.includes(m.id); + const disabled = !selected && selectedIds.length >= MAX_COMPARE; + return ( + + ); + })} +
+ + {/* Comparison table */} + {selectedMethods.length >= 2 && ( +
+ + + + + {selectedMethods.map((m) => ( + + ))} + + + + `${CATEGORIES[m.category].icon} ${CATEGORIES[m.category].label}`)} /> + DIFFICULTIES[m.difficulty].label)} /> + EFFECTIVENESS_LEVELS[m.effectiveness].label)} /> + m.timeframe)} /> + ( +
    + {m.pros.map((p) =>
  • {p}
  • )} +
+ ))} + /> + ( +
    + {m.cons.map((c) =>
  • {c}
  • )} +
+ ))} + /> + m.scientificBasis)} /> + + m.contraindications.length > 0 + ? m.contraindications.join(", ") + : "Нет", + )} + /> +
+
+ Параметр + + + {m.title} + +
+
+ )} + + {selectedMethods.length < 2 && selectedMethods.length > 0 && ( +
+ Выберите ещё {2 - selectedMethods.length} методик{selectedMethods.length === 1 ? "у" : "и"} для сравнения +
+ )} + + {selectedMethods.length === 0 && ( +
+ Выберите методики из списка выше +
+ )} +
+ ); +} + +function CompareRow({ label, items }: { label: string; items: React.ReactNode[] }) { + return ( + + + {label} + + {items.map((item, i) => ( + + {item} + + ))} + + ); +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 2786e98..54a4c8d 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file