From cc23cc2a1ef5079cf88972eeb37d01515da54c1f Mon Sep 17 00:00:00 2001 From: NikitaTorbenko <2015nekitciti@gmail.com> Date: Fri, 3 Apr 2026 20:06:47 +0300 Subject: [PATCH] feat: add themes --- .../src/app/providers/routing/routing.tsx | 2 + .../theme-changer/config/theme.config.ts | 106 ++++++++++++++++++ frontend/src/modules/theme-changer/index.ts | 2 + .../provider/theme.initial.provider.tsx | 13 +++ .../modules/theme-changer/types/theme.type.ts | 13 +++ .../ui/components/theme.card.tsx | 78 +++++++++++++ .../theme-changer/ui/theme.changer.tsx | 48 ++++++++ .../theme-changer/utils/apply.theme.ts | 105 +++++++++++++++++ frontend/src/pages/themes.page.tsx | 9 ++ 9 files changed, 376 insertions(+) create mode 100644 frontend/src/modules/theme-changer/config/theme.config.ts create mode 100644 frontend/src/modules/theme-changer/index.ts create mode 100644 frontend/src/modules/theme-changer/provider/theme.initial.provider.tsx create mode 100644 frontend/src/modules/theme-changer/types/theme.type.ts create mode 100644 frontend/src/modules/theme-changer/ui/components/theme.card.tsx create mode 100644 frontend/src/modules/theme-changer/ui/theme.changer.tsx create mode 100644 frontend/src/modules/theme-changer/utils/apply.theme.ts create mode 100644 frontend/src/pages/themes.page.tsx diff --git a/frontend/src/app/providers/routing/routing.tsx b/frontend/src/app/providers/routing/routing.tsx index c1e57a1..62bb7ab 100644 --- a/frontend/src/app/providers/routing/routing.tsx +++ b/frontend/src/app/providers/routing/routing.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom"; import { HomePage } from "@/pages/home.page"; +import { ThemesPage } from "@/pages/themes.page"; export const Routing = () => { return ( @@ -13,6 +14,7 @@ export const Routing = () => { > } /> + } /> } /> diff --git a/frontend/src/modules/theme-changer/config/theme.config.ts b/frontend/src/modules/theme-changer/config/theme.config.ts new file mode 100644 index 0000000..0214bfc --- /dev/null +++ b/frontend/src/modules/theme-changer/config/theme.config.ts @@ -0,0 +1,106 @@ +export const themes = [ + { + id: "light", + name: "Светлая", + description: "Чистая светлая тема", + type: "light", + colors: { + primary: "#4f46e5", + background: "#ffffff", + surface: "#f8fafc", + text: "#1f2937", + border: "#e5e7eb", + }, + }, + { + id: "dark", + name: "Темная", + description: "Элегантная темная тема", + type: "dark", + colors: { + primary: "#6366f1", + background: "#0f172a", + surface: "#1e293b", + text: "#f1f5f9", + border: "#334155", + }, + }, + { + id: "nightowl", + name: "Night Owl", + description: "Тема вдохновленная редактором кода", + type: "dark", + colors: { + primary: "#7dd3fc", + background: "#011627", + surface: "#011e3c", + text: "#d6deeb", + border: "#1d3b53", + }, + }, + { + id: "sunset", + name: "Закат", + description: "Теплые оранжевые тона", + type: "dark", + colors: { + primary: "#f97316", + background: "#1c1917", + surface: "#292524", + text: "#fafaf9", + border: "#57534e", + }, + }, + { + id: "forest", + name: "Лес", + description: "Успокаивающая зеленая тема", + type: "dark", + colors: { + primary: "#22c55e", + background: "#052e16", + surface: "#14532d", + text: "#f0fdf4", + border: "#166534", + }, + }, + { + id: "ocean", + name: "Океан", + description: "Глубокие синие тона", + type: "dark", + colors: { + primary: "#06b6d4", + background: "#164e63", + surface: "#0e7490", + text: "#f0fdfd", + border: "#0891b2", + }, + }, + { + id: "lavender", + name: "Лаванда", + description: "Нежная фиолетовая тема", + type: "light", + colors: { + primary: "#a855f7", + background: "#faf5ff", + surface: "#f3e8ff", + text: "#581c87", + border: "#e9d5ff", + }, + }, + { + id: "coffee", + name: "Кофе", + description: "Уютная коричневая тема", + type: "dark", + colors: { + primary: "#d97706", + background: "#292524", + surface: "#44403c", + text: "#f5f5f4", + border: "#57534e", + }, + }, +]; diff --git a/frontend/src/modules/theme-changer/index.ts b/frontend/src/modules/theme-changer/index.ts new file mode 100644 index 0000000..b83c870 --- /dev/null +++ b/frontend/src/modules/theme-changer/index.ts @@ -0,0 +1,2 @@ +export { ThemeInitialProvider } from "./provider/theme.initial.provider"; +export { ThemeChanger } from "./ui/theme.changer"; diff --git a/frontend/src/modules/theme-changer/provider/theme.initial.provider.tsx b/frontend/src/modules/theme-changer/provider/theme.initial.provider.tsx new file mode 100644 index 0000000..a70d022 --- /dev/null +++ b/frontend/src/modules/theme-changer/provider/theme.initial.provider.tsx @@ -0,0 +1,13 @@ +import { useLayoutEffect } from "react"; +import { applyTheme, initializeTheme } from "../utils/apply.theme"; + +export const ThemeInitialProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + useLayoutEffect(() => { + const theme = initializeTheme(); + applyTheme(theme); + }, []); + + return children; +}; diff --git a/frontend/src/modules/theme-changer/types/theme.type.ts b/frontend/src/modules/theme-changer/types/theme.type.ts new file mode 100644 index 0000000..5bc74d2 --- /dev/null +++ b/frontend/src/modules/theme-changer/types/theme.type.ts @@ -0,0 +1,13 @@ +export interface ITheme { + id: string; + name: string; + description: string; + type: string; + colors: { + primary: string; + background: string; + surface: string; + text: string; + border: string; + }; +} diff --git a/frontend/src/modules/theme-changer/ui/components/theme.card.tsx b/frontend/src/modules/theme-changer/ui/components/theme.card.tsx new file mode 100644 index 0000000..a883dd7 --- /dev/null +++ b/frontend/src/modules/theme-changer/ui/components/theme.card.tsx @@ -0,0 +1,78 @@ +import type { ITheme } from "../../types/theme.type"; + +interface IProps { + theme: ITheme; + isSelected: boolean; + onSelect: (id: string) => void; +} + +export const ThemeCard: React.FC = ({ + theme, + isSelected, + onSelect, +}) => { + const { id, name, description } = theme; + + return ( + + ); +}; diff --git a/frontend/src/modules/theme-changer/ui/theme.changer.tsx b/frontend/src/modules/theme-changer/ui/theme.changer.tsx new file mode 100644 index 0000000..d4816c5 --- /dev/null +++ b/frontend/src/modules/theme-changer/ui/theme.changer.tsx @@ -0,0 +1,48 @@ +import type { ITheme } from "../types/theme.type"; +import { applyTheme } from "../utils/apply.theme"; +import { ThemeCard } from "./components/theme.card"; +import { themes as baseThemes } from "../config/theme.config"; + +interface IProps { + themes?: ITheme[]; + label: string; + currentTheme?: string; + setTheme?: (id: string) => void; +} + +export const ThemeChanger: React.FC = ({ + themes = baseThemes, + label, + currentTheme, + setTheme, +}) => { + const onSelectTheme = (theme: string) => { + applyTheme(theme); + setTheme?.(theme); + }; + + return ( +
+

+ + + + {label} +

+
+ {themes.map((theme) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/modules/theme-changer/utils/apply.theme.ts b/frontend/src/modules/theme-changer/utils/apply.theme.ts new file mode 100644 index 0000000..3d3fb26 --- /dev/null +++ b/frontend/src/modules/theme-changer/utils/apply.theme.ts @@ -0,0 +1,105 @@ +import { themes } from "../config/theme.config"; + +export const applyTheme = (themeId: string) => { + const theme = themes.find((t) => t.id === themeId); + const root = document.documentElement; + + if (theme) { + try { + root.setAttribute("data-theme", themeId); + localStorage.setItem("theme", themeId); + localStorage.setItem("theme-type", theme.type); + + window.dispatchEvent( + new CustomEvent("themechange", { + detail: { theme: themeId, type: theme.type }, + }), + ); + } catch (error) { + console.error("❌ Error applying theme:", error); + } + } else { + console.warn(`⚠️ Theme not found: ${themeId}, falling back to light theme`); + applyTheme("light"); + } +}; + +export const getSavedTheme = () => { + try { + return localStorage.getItem("theme") || "light"; + } catch (error) { + console.error("Error reading theme from localStorage:", error); + return "light"; + } +}; + +export const initializeTheme = () => { + const savedTheme = getSavedTheme(); + + const themeExists = themes.some((t) => t.id === savedTheme); + const themeToApply = themeExists ? savedTheme : "light"; + + applyTheme(themeToApply); + return themeToApply; +}; + +export const getCurrentTheme = () => { + return document.documentElement.getAttribute("data-theme") || "light"; +}; + +export const getCurrentThemeType = () => { + const currentTheme = getCurrentTheme(); + const theme = themes.find((t) => t.id === currentTheme); + return theme ? theme.type : "light"; +}; + +export const toggleDarkLight = () => { + const currentTheme = getCurrentTheme(); + const currentThemeData = themes.find((t) => t.id === currentTheme); + + if (currentThemeData) { + const oppositeThemes = themes.filter( + (t) => t.type !== currentThemeData.type, + ); + if (oppositeThemes.length > 0) { + applyTheme(oppositeThemes[0].id); + return oppositeThemes[0].id; + } + } + + const newTheme = currentTheme === "light" ? "dark" : "light"; + applyTheme(newTheme); + return newTheme; +}; + +export const getNextTheme = () => { + const currentTheme = getCurrentTheme(); + const currentIndex = themes.findIndex((t) => t.id === currentTheme); + const nextIndex = (currentIndex + 1) % themes.length; + return themes[nextIndex].id; +}; + +export const applySystemTheme = () => { + if ( + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + applyTheme("dark"); + } else { + applyTheme("light"); + } +}; + +export const watchSystemTheme = () => { + if (window.matchMedia) { + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", (e) => { + if (e.matches) { + applyTheme("dark"); + } else { + applyTheme("light"); + } + }); + } +}; diff --git a/frontend/src/pages/themes.page.tsx b/frontend/src/pages/themes.page.tsx new file mode 100644 index 0000000..fa02cc9 --- /dev/null +++ b/frontend/src/pages/themes.page.tsx @@ -0,0 +1,9 @@ +import { ThemeChanger } from "@/modules/theme-changer"; + +export const ThemesPage = () => { + return ( +
+ +
+ ); +};