diff --git a/locales/en.json b/locales/en.json index 7debd54..b32f70e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -157,6 +157,13 @@ "journal.pl.theme.switchDark": "Switch to dark theme", "journal.pl.theme.switchLight": "Switch to light theme", + "journal.pl.theme.select": "Select theme", + "journal.pl.theme.light": "Light", + "journal.pl.theme.dark": "Dark", + "journal.pl.theme.pink": "Pink", + "journal.pl.theme.blue": "Blue", + "journal.pl.theme.green": "Green", + "journal.pl.theme.purple": "Purple", "journal.pl.lang.switchToEn": "Switch to English", "journal.pl.lang.switchToRu": "Switch to Russian", diff --git a/locales/ru.json b/locales/ru.json index 81cd0cd..f69defb 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -154,6 +154,13 @@ "journal.pl.theme.switchDark": "Переключить на темную тему", "journal.pl.theme.switchLight": "Переключить на светлую тему", + "journal.pl.theme.select": "Выбрать тему", + "journal.pl.theme.light": "Светлая", + "journal.pl.theme.dark": "Тёмная", + "journal.pl.theme.pink": "Розовая", + "journal.pl.theme.blue": "Синяя", + "journal.pl.theme.green": "Зелёная", + "journal.pl.theme.purple": "Фиолетовая", "journal.pl.lang.switchToEn": "Переключить на английский", "journal.pl.lang.switchToRu": "Переключить на русский", diff --git a/src/__data__/slices/theme.ts b/src/__data__/slices/theme.ts new file mode 100644 index 0000000..be74b36 --- /dev/null +++ b/src/__data__/slices/theme.ts @@ -0,0 +1,69 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { ThemeType } from '../../types/theme'; +import { + LIGHT_THEME, + DARK_THEME, + PINK_THEME, + BLUE_THEME, + GREEN_THEME, + PURPLE_THEME, + THEMES, + getNextTheme +} from '../../utils/themes'; + +// Ключ для хранения текущей темы в localStorage +const THEME_STORAGE_KEY = 'journal-pl-theme'; + +// Получаем сохраненную тему из localStorage или используем светлую тему по умолчанию +const getSavedTheme = (): ThemeType => { + if (typeof window !== 'undefined') { + const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null; + if (savedTheme && THEMES.includes(savedTheme as ThemeType)) { + return savedTheme as ThemeType; + } + } + // По умолчанию используем светлую тему + return LIGHT_THEME; +}; + +interface ThemeState { + currentTheme: ThemeType; +} + +const initialState: ThemeState = { + currentTheme: getSavedTheme(), +}; + +export const themeSlice = createSlice({ + name: 'theme', + initialState, + reducers: { + setTheme: (state, action: PayloadAction) => { + state.currentTheme = action.payload; + + // Сохраняем выбранную тему в localStorage + if (typeof window !== 'undefined') { + localStorage.setItem(THEME_STORAGE_KEY, action.payload); + } + }, + cycleNextTheme: (state) => { + state.currentTheme = getNextTheme(state.currentTheme); + + // Сохраняем выбранную тему в localStorage + if (typeof window !== 'undefined') { + localStorage.setItem(THEME_STORAGE_KEY, state.currentTheme); + } + }, + }, +}); + +export const { setTheme, cycleNextTheme } = themeSlice.actions; + +// Селекторы для получения информации о текущей теме +export const selectCurrentTheme = (state: { theme: ThemeState }) => state.theme.currentTheme; +export const selectIsLightVariant = (state: { theme: ThemeState }) => + [LIGHT_THEME, PINK_THEME, BLUE_THEME, GREEN_THEME].includes(state.theme.currentTheme); +export const selectIsDarkVariant = (state: { theme: ThemeState }) => + [DARK_THEME, PURPLE_THEME].includes(state.theme.currentTheme); + +export default themeSlice.reducer; \ No newline at end of file diff --git a/src/__data__/store.ts b/src/__data__/store.ts index 94f5eda..6be95b0 100644 --- a/src/__data__/store.ts +++ b/src/__data__/store.ts @@ -3,6 +3,7 @@ import { TypedUseSelectorHook, useSelector } from 'react-redux' import { api } from './api/api' import { userSlice } from './slices/user' +import { themeSlice } from './slices/theme' export const createStore = (preloadedState = {}) => configureStore({ @@ -10,6 +11,7 @@ export const createStore = (preloadedState = {}) => reducer: { [api.reducerPath]: api.reducer, [userSlice.name]: userSlice.reducer, + [themeSlice.name]: themeSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/src/app.tsx b/src/app.tsx index 82cb40d..941182e 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -4,18 +4,12 @@ import { Global } from '@emotion/react' import { BrowserRouter } from 'react-router-dom'; import dayjs from './utils/dayjs-config'; import { useTranslation } from 'react-i18next'; -import { ChakraProvider, ColorModeScript, extendTheme } from '@chakra-ui/react' +import { ChakraProvider, ColorModeScript } from '@chakra-ui/react' +import { Provider } from 'react-redux' import { Dashboard } from './dashboard'; import { globalStyles } from './global.styles'; - -// Расширяем тему Chakra UI -const theme = extendTheme({ - config: { - initialColorMode: 'light', - useSystemColorMode: false, - }, -}) +import { chakraTheme } from './utils/theme'; interface AppProps { store: any; // Тип для store зависит от конкретной реализации хранилища @@ -24,17 +18,19 @@ interface AppProps { const App: React.FC = ({ store }) => { const { t } = useTranslation(); return ( - - + + + {t('journal.pl.title')} - + + ) } diff --git a/src/components/app-header/app-header.tsx b/src/components/app-header/app-header.tsx index 4e39a76..4d7aed4 100644 --- a/src/components/app-header/app-header.tsx +++ b/src/components/app-header/app-header.tsx @@ -21,6 +21,10 @@ import { MoonIcon, SunIcon, ChevronRightIcon, InfoIcon, ChevronDownIcon } from ' import { useTranslation } from 'react-i18next'; import { Link, useLocation } from 'react-router-dom'; import { getNavigationValue } from '@brojs/cli'; +import { ThemeSelector } from '../theme-selector'; +import { useThemeManager } from '../../hooks/useThemeManager'; +import { useAppSelector } from '../../__data__/store'; +import { selectCurrentTheme } from '../../__data__/slices/theme'; interface AppHeaderProps { serviceMenuContainerRef?: React.RefObject; @@ -32,7 +36,9 @@ interface AppHeaderProps { } export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderProps) => { - const { colorMode, toggleColorMode } = useColorMode(); + const { isLightVariant, isDarkVariant } = useThemeManager(); + // Используем напрямую селектор из Redux для получения темы и для передачи в key + const currentTheme = useAppSelector(selectCurrentTheme); const { t, i18n } = useTranslation(); const location = useLocation(); @@ -55,11 +61,11 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro }; // Определяем размеры для разных устройств - const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' }); + const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' }, { ssr: false }); // Проверяем, на каком устройстве находимся - const [isLargerThan768] = useMediaQuery("(min-width: 768px)"); - const [isLargerThan480] = useMediaQuery("(min-width: 480px)"); + const [isLargerThan768] = useMediaQuery("(min-width: 768px)", { ssr: false }); + const [isLargerThan480] = useMediaQuery("(min-width: 480px)", { ssr: false }); // Вертикальное отображение на мобильных устройствах const isMobile = !isLargerThan480; @@ -68,23 +74,25 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro const horizontalSeparator = useBreakpointValue({ sm: , md: - }); + }, { ssr: false }); const toggleLanguage = () => { const newLang = i18n.language === 'ru' ? 'en' : 'ru'; i18n.changeLanguage(newLang); }; + // Используем key={currentTheme} для принудительного перерендеринга при изменении темы return ( {/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */} {serviceMenuContainerRef && ( @@ -126,18 +134,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro {i18n.language === 'ru' ? 'EN' : 'RU'} - : } - onClick={toggleColorMode} - variant="ghost" - size={{ base: "sm" }} - minW={{ base: "30px" }} - h={{ base: "30px" }} - /> + @@ -157,7 +154,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro py={1} borderRadius="md" _hover={!crumb.isCurrentPage && crumb.path ? { - bg: colorMode === 'light' ? 'gray.50' : 'gray.700', + bg: isLightVariant ? 'gray.50' : 'gray.700', } : {}} > {index > 0 && ( @@ -184,7 +181,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro ) : ( {crumb.title} @@ -254,18 +251,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro {i18n.language === 'ru' ? 'EN' : 'RU'} - : } - onClick={toggleColorMode} - variant="ghost" - size={{ sm: "sm", md: "md" }} - minW={{ sm: "34px" }} - h={{ sm: "34px" }} - /> + )} diff --git a/src/components/lesson/StudentCard.tsx b/src/components/lesson/StudentCard.tsx index bc147a1..f9fc28e 100644 --- a/src/components/lesson/StudentCard.tsx +++ b/src/components/lesson/StudentCard.tsx @@ -4,13 +4,13 @@ import { motion } from 'framer-motion' import { User, Reaction } from '../../__data__/model' import { UserCard } from '../user-card' import { StudentCardBack } from './StudentCardBack' -import { useColorMode } from '@chakra-ui/react' +import { useThemeManager } from '../../hooks/useThemeManager' // Компонент маленькой батарейки для отображения в углу карточки const BatteryIndicator: React.FC<{ student: User & { present?: boolean; recentlyPresent?: boolean } }> = ({ student }) => { - const { colorMode } = useColorMode(); + const { isLightVariant, isDarkVariant } = useThemeManager(); // Та же логика из StudentCardBack для определения уровня батареи const getAttendanceLevel = () => { @@ -28,17 +28,17 @@ const BatteryIndicator: React.FC<{ // Цвета для разных уровней заряда батареи const colors = [ // Empty (0) - { primary: colorMode === "light" ? "#E53E3E" : "#F56565" }, + { primary: isLightVariant ? "#E53E3E" : "#F56565" }, // Very Low (1) - { primary: colorMode === "light" ? "#DD6B20" : "#ED8936" }, + { primary: isLightVariant ? "#DD6B20" : "#ED8936" }, // Low (2) - { primary: colorMode === "light" ? "#D69E2E" : "#ECC94B" }, + { primary: isLightVariant ? "#D69E2E" : "#ECC94B" }, // Medium (3) - { primary: colorMode === "light" ? "#38B2AC" : "#4FD1C5" }, + { primary: isLightVariant ? "#38B2AC" : "#4FD1C5" }, // Good (4) - { primary: colorMode === "light" ? "#3182CE" : "#4299E1" }, + { primary: isLightVariant ? "#3182CE" : "#4299E1" }, // Excellent (5) - { primary: colorMode === "light" ? "#38A169" : "#48BB78" } + { primary: isLightVariant ? "#38A169" : "#48BB78" } ]; const color = colors[batteryLevel].primary; @@ -108,6 +108,8 @@ export const StudentCard: React.FC = ({ onAddUser, reaction }) => { + const { isLightVariant } = useThemeManager(); + return ( = ({ /> {/* Battery indicator in corner */} - + {/* */} {/* Back side */} diff --git a/src/components/lesson/StudentCardBack.tsx b/src/components/lesson/StudentCardBack.tsx index f9db155..508bd1b 100644 --- a/src/components/lesson/StudentCardBack.tsx +++ b/src/components/lesson/StudentCardBack.tsx @@ -1,10 +1,11 @@ import React from 'react' -import { Box, Flex, useColorMode } from '@chakra-ui/react' +import { Box, Flex, useColorMode, Text } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { User } from '../../__data__/model' import { AddMissedButton } from './sstyle' import { AddIcon } from '@chakra-ui/icons' import { UserCard } from '../user-card' +import { useThemeManager } from '../../hooks/useThemeManager' interface StudentCardBackProps { student: User & { present?: boolean; recentlyPresent?: boolean } @@ -14,35 +15,107 @@ interface StudentCardBackProps { export const StudentCardBack: React.FC = ({ student, onAddUser }) => { const { colorMode } = useColorMode() const { t } = useTranslation() - - // Веселые градиентные цвета - const colors = { - gradient: colorMode === 'light' - ? 'linear-gradient(135deg, #F6FFDE, #E3F2C1, #C9DBB2)' - : 'linear-gradient(135deg, #0D1282, #2F58CD, #3795BD)', - text: colorMode === 'light' ? "#2E7D32" : "#81C784" - } + const { isLightVariant, currentTheme } = useThemeManager() // Is the student marked as present? const isPresent = !!student.present; - // Функция для генерации случайного, но стабильного положения пузырьков для каждого студента - const getBubbleStyle = (index: number) => { - // Используем ID студента для получения стабильных, но уникальных чисел - const id = student.sub || ''; - const charSum = id.split('').reduce((sum, char, i) => sum + char.charCodeAt(0) * (i + 1), 0); - - // Разные значения для разных пузырей - const seed = (charSum + index * 137) % 100; - - return { - left: `${(seed % 80) + 10}%`, - top: `${((seed * 1.5) % 80) + 10}%`, - size: `${(seed % 15) + 10}px`, - duration: `${(seed % 4) + 8}s` - }; + // Темы для карточки + const themeStyles = { + // Стандартная светлая тема + light: { + gradient: 'linear-gradient(135deg, rgba(226, 232, 240, 0.6), rgba(255, 255, 255, 0.9))', + text: "#2E7D32", + infoText: "gray.600", + shadowColor: 'rgba(0, 0, 0, 0.1)', + background: "white" + }, + // Стандартная темная тема + dark: { + gradient: 'linear-gradient(135deg, rgba(30, 30, 30, 0.95), rgba(45, 55, 72, 0.7))', + text: "#81C784", + infoText: "gray.300", + shadowColor: 'rgba(255, 255, 255, 0.2)', + background: "gray.700" + }, + // Синяя тема + blue: { + gradient: isLightVariant + ? 'linear-gradient(135deg, rgba(235, 244, 255, 0.8), rgba(202, 228, 255, 0.9))' + : 'linear-gradient(135deg, rgba(10, 37, 64, 0.95), rgba(28, 69, 118, 0.8))', + text: isLightVariant ? "#1A365D" : "#63B3ED", + infoText: isLightVariant ? "blue.700" : "blue.200", + shadowColor: isLightVariant ? 'rgba(40, 80, 150, 0.1)' : 'rgba(120, 180, 255, 0.2)', + background: isLightVariant ? "white" : "gray.800" + }, + // Зеленая тема + green: { + gradient: isLightVariant + ? 'linear-gradient(135deg, rgba(235, 255, 240, 0.8), rgba(210, 250, 215, 0.9))' + : 'linear-gradient(135deg, rgba(10, 64, 37, 0.95), rgba(28, 118, 69, 0.8))', + text: isLightVariant ? "#22543D" : "#68D391", + infoText: isLightVariant ? "green.700" : "green.200", + shadowColor: isLightVariant ? 'rgba(40, 150, 80, 0.1)' : 'rgba(120, 255, 180, 0.2)', + background: isLightVariant ? "white" : "gray.800" + }, + // Фиолетовая тема + purple: { + gradient: isLightVariant + ? 'linear-gradient(135deg, rgba(245, 240, 255, 0.8), rgba(230, 215, 250, 0.9))' + : 'linear-gradient(135deg, rgba(44, 16, 74, 0.95), rgba(79, 32, 130, 0.8))', + text: isLightVariant ? "#553C9A" : "#B794F4", + infoText: isLightVariant ? "purple.700" : "purple.200", + shadowColor: isLightVariant ? 'rgba(100, 40, 150, 0.1)' : 'rgba(180, 120, 255, 0.2)', + background: isLightVariant ? "white" : "gray.800" + }, + // Янтарная тема + amber: { + gradient: isLightVariant + ? 'linear-gradient(135deg, rgba(255, 250, 235, 0.8), rgba(255, 235, 200, 0.9))' + : 'linear-gradient(135deg, rgba(74, 50, 16, 0.95), rgba(130, 90, 32, 0.8))', + text: isLightVariant ? "#7B341E" : "#FBBF24", + infoText: isLightVariant ? "orange.700" : "yellow.200", + shadowColor: isLightVariant ? 'rgba(150, 100, 40, 0.1)' : 'rgba(255, 180, 120, 0.2)', + background: isLightVariant ? "white" : "gray.800" + }, + // Розовая тема + pink: { + gradient: isLightVariant + ? 'linear-gradient(135deg, rgba(255, 240, 245, 0.8), rgba(252, 217, 234, 0.9))' + : 'linear-gradient(135deg, rgba(74, 16, 50, 0.95), rgba(139, 39, 116, 0.8))', + text: isLightVariant ? "#97266D" : "#F687B3", + infoText: isLightVariant ? "pink.700" : "pink.200", + shadowColor: isLightVariant ? 'rgba(150, 40, 100, 0.1)' : 'rgba(255, 150, 210, 0.2)', + background: isLightVariant ? "white" : "gray.800" + } }; + // Определяем текущую тему + const getCurrentTheme = () => { + // Базовая логика - используем темную/светлую тему + let theme = isLightVariant ? 'light' : 'dark'; + + // Если доступна информация о текущей теме, используем соответствующую + if (currentTheme) { + if (currentTheme.includes('blue')) { + theme = 'blue'; + } else if (currentTheme.includes('green')) { + theme = 'green'; + } else if (currentTheme.includes('purple')) { + theme = 'purple'; + } else if (currentTheme.includes('pink')) { + theme = 'pink'; + } else if (currentTheme.includes('amber') || currentTheme.includes('orange') || currentTheme.includes('yellow')) { + theme = 'amber'; + } + } + + return themeStyles[theme as keyof typeof themeStyles]; + }; + + // Получаем стили для текущей темы + const theme = getCurrentTheme(); + return ( = ({ student, onAdd left="0" width="100%" height="100%" - bg={colorMode === "light" ? "white" : "gray.700"} + bg={theme.background} borderRadius="12px" align="center" justify="center" @@ -73,42 +146,17 @@ export const StudentCardBack: React.FC = ({ student, onAdd )} - {/* Веселый фон с градиентом */} + {/* Фон с градиентом */} - {/* Декоративные пузырьки */} - {[...Array(6)].map((_, i) => { - const style = getBubbleStyle(i); - return ( - - ); - })} - {/* Content */} = ({ student, onAdd width="100%" zIndex="1" > - {/* Gravatar с весёлой анимацией */} + {/* Аватар */} = ({ student, onAdd borderRadius="full" overflow="hidden" mb={4} - boxShadow="0 0 15px rgba(255, 255, 255, 0.4)" + boxShadow={`0 0 15px ${theme.shadowColor}`} > - {/* Using the UserCard for gravatar */} = ({ student, onAdd fontSize="sm" fontWeight="medium" textAlign="center" - color={colorMode === "light" ? "gray.800" : "white"} - mb={1} + color={theme.text} + mb={3} > {student.name || student.preferred_username} - {/* Status с эмодзи */} - - 🔔 - {t('journal.pl.lesson.notMarked')} - + {isPresent + ? t('journal.pl.lesson.presentToday') + : t('journal.pl.lesson.notMarked')} + ) diff --git a/src/components/page-loader/page-loader.tsx b/src/components/page-loader/page-loader.tsx index 94ec2ae..534ae78 100644 --- a/src/components/page-loader/page-loader.tsx +++ b/src/components/page-loader/page-loader.tsx @@ -5,9 +5,10 @@ import { Center, useColorMode } from '@chakra-ui/react' +import { useThemeManager } from '../../hooks/useThemeManager'; export const PageLoader = () => { - const { colorMode } = useColorMode(); + const { isLightVariant } = useThemeManager(); return ( @@ -15,8 +16,8 @@ export const PageLoader = () => { diff --git a/src/components/theme-selector/index.ts b/src/components/theme-selector/index.ts new file mode 100644 index 0000000..95fef1d --- /dev/null +++ b/src/components/theme-selector/index.ts @@ -0,0 +1 @@ +export { ThemeSelector } from './theme-selector'; \ No newline at end of file diff --git a/src/components/theme-selector/theme-selector.tsx b/src/components/theme-selector/theme-selector.tsx new file mode 100644 index 0000000..ad5450e --- /dev/null +++ b/src/components/theme-selector/theme-selector.tsx @@ -0,0 +1,133 @@ +import React, { useState, useCallback, MouseEvent } from 'react'; +import { + Menu, + MenuButton, + MenuList, + MenuItem, + IconButton, + Box, + Tooltip, +} from '@chakra-ui/react'; +import { + MoonIcon, + SunIcon, +} from '@chakra-ui/icons'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useAppSelector } from '../../__data__/store'; +import { selectCurrentTheme, setTheme, cycleNextTheme } from '../../__data__/slices/theme'; +import { ThemeType } from '../../types/theme'; +import { + LIGHT_THEME, + DARK_THEME, + PINK_THEME, + BLUE_THEME, + GREEN_THEME, + PURPLE_THEME, + THEMES +} from '../../utils/themes'; + +// Иконки для различных тем +const ThemeIcon: React.FC<{ theme: ThemeType }> = ({ theme }) => { + switch (theme) { + case LIGHT_THEME: + return ; + case DARK_THEME: + return ; + case PINK_THEME: + return 💕; + case BLUE_THEME: + return 🌊; + case GREEN_THEME: + return 🌿; + case PURPLE_THEME: + return ; + default: + return ; + } +}; + +// Цвета фона пунктов меню для предпросмотра +const getMenuItemStyles = (theme: ThemeType) => { + switch (theme) { + case LIGHT_THEME: + return { bg: 'white', color: 'black' }; + case DARK_THEME: + return { bg: 'gray.800', color: 'white' }; + case PINK_THEME: + return { bg: 'pink.100', color: 'pink.800' }; + case BLUE_THEME: + return { bg: 'blue.100', color: 'blue.800' }; + case GREEN_THEME: + return { bg: 'green.100', color: 'green.800' }; + case PURPLE_THEME: + return { bg: 'purple.100', color: 'purple.800' }; + default: + return {}; + } +}; + +interface ThemeSelectorProps { + variant?: 'icon' | 'full'; + size?: string; +} + +export const ThemeSelector: React.FC = ({ + variant = 'icon', + size = 'md' +}) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const currentTheme = useAppSelector(selectCurrentTheme); + + // Состояние для контроля открытия меню + const [isMenuOpen, setIsMenuOpen] = useState(false); + + // Обработчик клика по иконке + const handleIconClick = useCallback((e: MouseEvent) => { + // Если нажат Ctrl, отображаем меню + if (e.ctrlKey) { + setIsMenuOpen(true); + } else { + // Иначе просто циклически меняем тему + dispatch(cycleNextTheme()); + } + }, [dispatch]); + + // Обработчик выбора темы из меню + const handleThemeChange = (theme: ThemeType) => { + dispatch(setTheme(theme)); + setIsMenuOpen(false); + }; + + // Рендерим одиночную иконку с меню при Ctrl+клик + return ( + setIsMenuOpen(false)}> + + } + variant="ghost" + size={size} + onClick={handleIconClick} + /> + + + {THEMES.map((theme) => ( + handleThemeChange(theme)} + icon={} + {...getMenuItemStyles(theme)} + > + {t(`journal.pl.theme.${theme}`)} + + ))} + + + ); +}; \ No newline at end of file diff --git a/src/components/user-card/user-card.tsx b/src/components/user-card/user-card.tsx index 1b7c1cb..0215ab3 100644 --- a/src/components/user-card/user-card.tsx +++ b/src/components/user-card/user-card.tsx @@ -5,6 +5,7 @@ import { Box, useColorMode, Text } from '@chakra-ui/react' import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons' import { motion, AnimatePresence } from 'framer-motion' import { useTranslation } from 'react-i18next' +import { useThemeManager } from '../../hooks/useThemeManager' import { Reaction, User } from '../../__data__/model' @@ -47,6 +48,7 @@ export const UserCard = ({ const [imageError, setImageError] = useState(false); const [showReaction, setShowReaction] = useState(false); const timeoutRef = useRef(null); + const { isLightVariant } = useThemeManager(); const randomGravatarPath = useMemo(() => Math.random() * 1000, []) @@ -74,6 +76,53 @@ export const UserCard = ({ }; }, [reaction]); + // Функция для определения цвета статуса онлайн + const getStatusColor = (status: string) => { + switch (status) { + case 'online': + return 'green.500'; + case 'away': + return 'yellow.500'; + default: + return 'gray.400'; + } + }; + + // Функция для рендера индикатора посещаемости + const renderAttendanceIndicator = (attendance: number) => { + let color; + let text; + + if (attendance >= 90) { + color = 'green.500'; + text = '✓✓✓'; + } else if (attendance >= 70) { + color = 'green.400'; + text = '✓✓'; + } else if (attendance >= 50) { + color = 'yellow.500'; + text = '✓'; + } else if (attendance >= 30) { + color = 'orange.500'; + text = '⚠'; + } else { + color = 'red.500'; + text = '✗'; + } + + return ( + + {text} + + ); + }; + return ( )} + + {/* Дополнительные декоративные элементы в зависимости от темы */} + ) } diff --git a/src/components/xl-spinner/xl-spinner.tsx b/src/components/xl-spinner/xl-spinner.tsx index 9ba8964..7d5e335 100644 --- a/src/components/xl-spinner/xl-spinner.tsx +++ b/src/components/xl-spinner/xl-spinner.tsx @@ -5,13 +5,14 @@ import { Spinner, useColorMode } from '@chakra-ui/react' +import { useThemeManager } from '../../hooks/useThemeManager' interface XlSpinnerProps { size?: string; } export const XlSpinner: React.FC = ({ size = 'xl' }) => { - const { colorMode } = useColorMode(); + const { isLightVariant } = useThemeManager(); return ( @@ -19,8 +20,8 @@ export const XlSpinner: React.FC = ({ size = 'xl' }) => { diff --git a/src/dashboard.tsx b/src/dashboard.tsx index f353abc..5a57932 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,9 +1,9 @@ import React, { useEffect, Suspense, useRef, useState } from 'react' import { Routes, Route, useNavigate } from 'react-router-dom' -import { Provider } from 'react-redux' import { getNavigationValue } from '@brojs/cli' import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' +import { useThemeManager } from './hooks/useThemeManager' import { CourseListPage, @@ -68,15 +68,12 @@ const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContain return ; }; -interface DashboardProps { - store: any; // Используем any, поскольку точный тип store не указан -} -export const Dashboard = ({ store }: DashboardProps) => { +export const Dashboard = () => { const serviceMenuContainerRef = useRef(null); const serviceMenuInstanceRef = useRef(null); const [serviceMenu, setServiceMenu] = useState(false); - const { colorMode } = useColorMode(); + const { isLightVariant, isDarkVariant } = useThemeManager(); const { t } = useTranslation(); useEffect(() => { @@ -84,7 +81,7 @@ export const Dashboard = ({ store }: DashboardProps) => { setServiceMenu(true) }).catch(console.error) }, []) - + useEffect(() => { // Проверяем, что библиотека загружена и есть контейнер для меню if (window.createServiceMenu && serviceMenuContainerRef.current && serviceMenu) { @@ -94,10 +91,10 @@ export const Dashboard = ({ store }: DashboardProps) => { apiUrl: 'https://admin.bro-js.ru', targetElement: serviceMenuContainerRef.current, styles: { - dotColor: colorMode === 'light' ? '#333' : '#ccc', - hoverColor: colorMode === 'light' ? '#eee' : '#444', - backgroundColor: colorMode === 'light' ? '#fff' : '#2D3748', - textColor: colorMode === 'light' ? '#333' : '#fff', + dotColor: isLightVariant ? '#333' : '#ccc', + hoverColor: isLightVariant ? '#eee' : '#444', + backgroundColor: isLightVariant ? '#fff' : '#2D3748', + textColor: isLightVariant ? '#333' : '#fff', }, translations: { menuTitle: t('journal.pl.serviceMenu.title'), @@ -105,7 +102,7 @@ export const Dashboard = ({ store }: DashboardProps) => { } }); } - + // Очистка при размонтировании return () => { if (serviceMenuInstanceRef.current) { @@ -113,10 +110,9 @@ export const Dashboard = ({ store }: DashboardProps) => { serviceMenuInstanceRef.current = null; } }; - }, [keycloak.token, serviceMenu, colorMode, t]); - + }, [keycloak.token, serviceMenu, isLightVariant, isDarkVariant, t]); + return ( - @@ -162,6 +158,5 @@ export const Dashboard = ({ store }: DashboardProps) => { /> - ) } diff --git a/src/global.styles.ts b/src/global.styles.ts index 907fbd4..839cd26 100644 --- a/src/global.styles.ts +++ b/src/global.styles.ts @@ -7,6 +7,8 @@ html { min-width: 320px; overflow: hidden; } + +/* Светлая тема (light) - по умолчанию */ body { color: #000; /* background: radial-gradient(circle at top right, rgb(154 227 33), rgb(33 160 56)); */ @@ -31,7 +33,7 @@ body { font-weight: 600; } -/* Стили для темной темы */ +/* Темная тема (dark) */ html[data-theme="dark"] body { color: #fff; background: radial-gradient( @@ -51,6 +53,86 @@ html[data-theme="dark"] body { ); } +/* Розовая тема (pink) */ +html[data-theme="pink"] body { + color: #702459; + background: radial-gradient( + farthest-side at bottom left, + rgba(251, 182, 206, 0.8), + rgba(255, 255, 255, 0) 65% + ), + radial-gradient( + farthest-corner at bottom center, + rgba(254, 215, 226, 0.7), + rgba(255, 255, 255, 0) 40% + ), + radial-gradient( + farthest-side at bottom right, + rgba(246, 135, 179, 0.6), + rgb(255, 245, 247) 65% + ); +} + +/* Синяя тема (blue) */ +html[data-theme="blue"] body { + color: #2c5282; + background: radial-gradient( + farthest-side at bottom left, + rgba(144, 205, 244, 0.8), + rgba(255, 255, 255, 0) 65% + ), + radial-gradient( + farthest-corner at bottom center, + rgba(190, 227, 248, 0.7), + rgba(255, 255, 255, 0) 40% + ), + radial-gradient( + farthest-side at bottom right, + rgba(66, 153, 225, 0.6), + rgb(235, 248, 255) 65% + ); +} + +/* Зеленая тема (green) */ +html[data-theme="green"] body { + color: #276749; + background: radial-gradient( + farthest-side at bottom left, + rgba(154, 230, 180, 0.8), + rgba(255, 255, 255, 0) 65% + ), + radial-gradient( + farthest-corner at bottom center, + rgba(198, 246, 213, 0.7), + rgba(255, 255, 255, 0) 40% + ), + radial-gradient( + farthest-side at bottom right, + rgba(72, 187, 120, 0.6), + rgb(240, 255, 244) 65% + ); +} + +/* Фиолетовая тема (purple) */ +html[data-theme="purple"] body { + color: #fff; + background: radial-gradient( + farthest-side at bottom left, + rgba(128, 90, 213, 0.8), + rgba(0, 0, 0, 0) 65% + ), + radial-gradient( + farthest-corner at bottom center, + rgba(159, 122, 234, 0.7), + rgba(0, 0, 0, 0) 40% + ), + radial-gradient( + farthest-side at bottom right, + rgba(107, 70, 193, 0.6), + rgb(44, 19, 56) 65% + ); +} + #app { height: 100%; overflow-y: auto; diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts new file mode 100644 index 0000000..d843942 --- /dev/null +++ b/src/hooks/useThemeManager.ts @@ -0,0 +1,132 @@ +import { useColorMode } from '@chakra-ui/react'; +import { useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { ThemeType } from '../types/theme'; +import { + LIGHT_THEME, + DARK_THEME, + PINK_THEME, + BLUE_THEME, + GREEN_THEME, + PURPLE_THEME, + THEMES +} from '../utils/themes'; +import { useAppSelector } from '../__data__/store'; +import { + setTheme, + cycleNextTheme as cycleTheme, + selectCurrentTheme, + selectIsLightVariant, + selectIsDarkVariant +} from '../__data__/slices/theme'; + +// Маппинг тем к базовым режимам Chakra UI +const themeToColorMode: Record = { + [LIGHT_THEME]: 'light', + [DARK_THEME]: 'dark', + [PINK_THEME]: 'light', + [BLUE_THEME]: 'light', + [GREEN_THEME]: 'light', + [PURPLE_THEME]: 'dark' +}; + +export const useThemeManager = () => { + // Получаем базовый функционал переключения темы из Chakra UI + const { colorMode, setColorMode } = useColorMode(); + + // Используем Redux для управления темой + const dispatch = useDispatch(); + const currentTheme = useAppSelector(selectCurrentTheme); + const isLightVariant = useAppSelector(selectIsLightVariant); + const isDarkVariant = useAppSelector(selectIsDarkVariant); + + // Ref для хранения observer + const observerRef = useRef(null); + + // Функция для применения классов и атрибутов темы + const applyThemeToDOM = () => { + if (typeof document === 'undefined') return; + + // Удаляем все классы тем + document.documentElement.classList.remove(...THEMES); + + // Добавляем класс текущей темы + document.documentElement.classList.add(currentTheme); + + // Также устанавливаем data-theme атрибут для использования в CSS + document.documentElement.setAttribute('data-theme', currentTheme); + + // Устанавливаем специальный флаг, что тема установлена нами + document.documentElement.setAttribute('data-custom-theme-source', 'redux'); + }; + + // Эффект для применения дополнительных классов к документу в зависимости от выбранной темы + // и создания MutationObserver для отслеживания изменений + useEffect(() => { + if (typeof document === 'undefined' || typeof window === 'undefined') return; + + // Применяем тему к DOM + applyThemeToDOM(); + + // Синхронизируем с Chakra UI для светлой/темной темы + const requiredColorMode = themeToColorMode[currentTheme]; + if (colorMode !== requiredColorMode) { + setColorMode(requiredColorMode); + } + + // Создаем MutationObserver для отслеживания изменений атрибута data-theme + if (!observerRef.current) { + observerRef.current = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'data-theme' && + document.documentElement.getAttribute('data-theme') !== currentTheme + ) { + // Если атрибут был изменен не нами, восстанавливаем его + applyThemeToDOM(); + } + }); + }); + + // Начинаем наблюдение за изменениями атрибута data-theme + observerRef.current.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + } + + // Для дополнительной защиты устанавливаем интервал для проверки и восстановления атрибута + const intervalId = setInterval(() => { + if (document.documentElement.getAttribute('data-theme') !== currentTheme) { + applyThemeToDOM(); + } + }, 500); + + // Отключаем observer и интервал при размонтировании компонента + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + clearInterval(intervalId); + }; + }, [currentTheme, setColorMode, colorMode]); + + // Функция для изменения темы + const changeTheme = (theme: ThemeType) => { + dispatch(setTheme(theme)); + }; + + // Функция для последовательного циклического переключения тем + const cycleNextTheme = () => { + dispatch(cycleTheme()); + }; + + return { + currentTheme, + changeTheme, + cycleNextTheme, + isLightVariant, + isDarkVariant, + }; +}; \ No newline at end of file diff --git a/src/types/theme.ts b/src/types/theme.ts new file mode 100644 index 0000000..ece6340 --- /dev/null +++ b/src/types/theme.ts @@ -0,0 +1 @@ +export type ThemeType = 'light' | 'dark' | 'pink' | 'blue' | 'green' | 'purple'; \ No newline at end of file diff --git a/src/user-card/user-card.tsx b/src/user-card/user-card.tsx new file mode 100644 index 0000000..729f7c2 --- /dev/null +++ b/src/user-card/user-card.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { Box, useColorMode, Text } from '@chakra-ui/react'; +import { useThemeManager } from '../../hooks/useThemeManager'; + +interface UserCardProps { + user: { + id: string; + name: string; + photo?: string; + role?: string; + status?: 'online' | 'offline' | 'away'; + attendance?: number; + }; + onClick?: () => void; + showStatus?: boolean; + showAttendance?: boolean; +} + +export const UserCard: React.FC = ({ + user, + onClick, + showStatus = false, + showAttendance = false +}) => { + const { isLightVariant } = useThemeManager(); + + // Функция для определения цвета статуса онлайн + const getStatusColor = (status: string) => { + switch (status) { + case 'online': + return 'green.500'; + case 'away': + return 'yellow.500'; + default: + return 'gray.400'; + } + }; + + // Функция для рендера индикатора посещаемости + const renderAttendanceIndicator = (attendance: number) => { + let color; + let text; + + if (attendance >= 90) { + color = 'green.500'; + text = '✓✓✓'; + } else if (attendance >= 70) { + color = 'green.400'; + text = '✓✓'; + } else if (attendance >= 50) { + color = 'yellow.500'; + text = '✓'; + } else if (attendance >= 30) { + color = 'orange.500'; + text = '⚠'; + } else { + color = 'red.500'; + text = '✗'; + } + + return ( + + {text} + + ); + }; + + return ( + + + {user.photo ? ( + + ) : ( + + {user.name.charAt(0).toUpperCase()} + + )} + + {showStatus && user.status && ( + + )} + + + + {user.name} + + + {user.role && ( + + {user.role} + + )} + + {showAttendance && typeof user.attendance === 'number' && renderAttendanceIndicator(user.attendance)} + + {/* Дополнительные декоративные элементы в зависимости от темы */} + + + ); +}; \ No newline at end of file diff --git a/src/utils/theme.ts b/src/utils/theme.ts new file mode 100644 index 0000000..ad08511 --- /dev/null +++ b/src/utils/theme.ts @@ -0,0 +1,105 @@ +import { extendTheme, ThemeConfig } from '@chakra-ui/react'; +import { + LIGHT_THEME, + DARK_THEME, + PINK_THEME, + BLUE_THEME, + GREEN_THEME, + PURPLE_THEME +} from './themes'; + +// Базовая конфигурация темы Chakra UI +const config: ThemeConfig = { + initialColorMode: LIGHT_THEME, + useSystemColorMode: false, +}; + +// Определяем специфичные цвета для различных тем +const colors = { + // Цвета для розовой темы + pinkTheme: { + 50: '#fff5f7', + 100: '#fed7e2', + 200: '#fbb6ce', + 300: '#f687b3', + 400: '#ed64a6', + 500: '#d53f8c', + 600: '#b83280', + 700: '#97266d', + 800: '#702459', + 900: '#521B41', + }, + // Цвета для синей темы + blueTheme: { + 50: '#ebf8ff', + 100: '#bee3f8', + 200: '#90cdf4', + 300: '#63b3ed', + 400: '#4299e1', + 500: '#3182ce', + 600: '#2b6cb0', + 700: '#2c5282', + 800: '#2a4365', + 900: '#1A365D', + }, + // Цвета для зеленой темы + greenTheme: { + 50: '#f0fff4', + 100: '#c6f6d5', + 200: '#9ae6b4', + 300: '#68d391', + 400: '#48bb78', + 500: '#38a169', + 600: '#2f855a', + 700: '#276749', + 800: '#22543d', + 900: '#1C4532', + }, + // Цвета для фиолетовой темы + purpleTheme: { + 50: '#faf5ff', + 100: '#e9d8fd', + 200: '#d6bcfa', + 300: '#b794f4', + 400: '#9f7aea', + 500: '#805ad5', + 600: '#6b46c1', + 700: '#553c9a', + 800: '#44337a', + 900: '#322659', + }, +}; + +// Создаем и экспортируем расширенную тему +export const chakraTheme = extendTheme({ + config, + colors, + styles: { + global: (props: { colorMode: string }) => ({ + // Базовые стили для темного и светлого режимов + body: { + bg: props.colorMode === 'dark' ? 'gray.800' : 'white', + color: props.colorMode === 'dark' ? 'white' : 'gray.800', + }, + }), + }, + components: { + // Настраиваем компоненты для поддержки дополнительных тем + Button: { + baseStyle: (props: { colorMode: string }) => ({ + _focus: { + boxShadow: + props.colorMode === PINK_THEME + ? '0 0 0 3px rgba(237, 100, 166, 0.6)' + : props.colorMode === BLUE_THEME + ? '0 0 0 3px rgba(66, 153, 225, 0.6)' + : props.colorMode === GREEN_THEME + ? '0 0 0 3px rgba(72, 187, 120, 0.6)' + : props.colorMode === PURPLE_THEME + ? '0 0 0 3px rgba(159, 122, 234, 0.6)' + : undefined, + }, + }), + }, + }, +}); \ No newline at end of file diff --git a/src/utils/themes.ts b/src/utils/themes.ts new file mode 100644 index 0000000..baa5865 --- /dev/null +++ b/src/utils/themes.ts @@ -0,0 +1,45 @@ +import { ThemeType } from '../types/theme'; + +// Константы для названий тем +export const LIGHT_THEME = 'light'; +export const DARK_THEME = 'dark'; +export const PINK_THEME = 'pink'; +export const BLUE_THEME = 'blue'; +export const GREEN_THEME = 'green'; +export const PURPLE_THEME = 'purple'; + +// Массив всех доступных тем для переключения +export const THEMES: ThemeType[] = [ + LIGHT_THEME, + DARK_THEME, + PINK_THEME, + BLUE_THEME, + GREEN_THEME, + PURPLE_THEME, +]; + +// Функция для получения следующей темы в списке +export const getNextTheme = (currentTheme: ThemeType): ThemeType => { + const currentIndex = THEMES.indexOf(currentTheme); + return THEMES[(currentIndex + 1) % THEMES.length]; +}; + +// Функция для получения иконки темы в зависимости от её типа +export const getThemeIcon = (theme: ThemeType): string => { + switch (theme) { + case LIGHT_THEME: + return 'sun'; + case DARK_THEME: + return 'moon'; + case PINK_THEME: + return 'heart'; + case BLUE_THEME: + return 'water'; + case GREEN_THEME: + return 'leaf'; + case PURPLE_THEME: + return 'stars'; + default: + return 'sun'; + } +}; \ No newline at end of file