vibe themes
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				platform/bro-js/journal.pl/pipeline/head This commit looks good
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	platform/bro-js/journal.pl/pipeline/head This commit looks good
				
			This commit is contained in:
		
							parent
							
								
									092577f192
								
							
						
					
					
						commit
						9cbc5910ef
					
				| @ -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", | ||||
|  | ||||
| @ -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": "Переключить на русский", | ||||
|  | ||||
							
								
								
									
										69
									
								
								src/__data__/slices/theme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/__data__/slices/theme.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<ThemeType>) => { | ||||
|       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;  | ||||
| @ -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({ | ||||
|  | ||||
							
								
								
									
										20
									
								
								src/app.tsx
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								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<AppProps> = ({ store }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   return ( | ||||
|     <ChakraProvider theme={theme}> | ||||
|         <ColorModeScript initialColorMode={theme.config.initialColorMode} /> | ||||
|     <Provider store={store}> | ||||
|     <ChakraProvider theme={chakraTheme}> | ||||
|         <ColorModeScript initialColorMode={chakraTheme.config.initialColorMode} /> | ||||
|         <BrowserRouter> | ||||
|             <Helmet> | ||||
|                 <meta name="viewport" content="width=device-width, user-scalable=no" /> | ||||
|                 <title>{t('journal.pl.title')}</title> | ||||
|             </Helmet> | ||||
|             <Global styles={globalStyles} /> | ||||
|             <Dashboard store={store} /> | ||||
|             <Dashboard /> | ||||
|         </BrowserRouter> | ||||
|     </ChakraProvider> | ||||
|     </Provider> | ||||
| ) | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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<HTMLDivElement>; | ||||
| @ -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: <ChevronRightIcon color="gray.400" fontSize="xs" />,  | ||||
|     md: <ChevronRightIcon color="gray.400" />  | ||||
|   }); | ||||
|   }, { ssr: false }); | ||||
| 
 | ||||
|   const toggleLanguage = () => { | ||||
|     const newLang = i18n.language === 'ru' ? 'en' : 'ru'; | ||||
|     i18n.changeLanguage(newLang); | ||||
|   }; | ||||
| 
 | ||||
|   // Используем key={currentTheme} для принудительного перерендеринга при изменении темы
 | ||||
|   return ( | ||||
|     <Box  | ||||
|       as="header"  | ||||
|       width="100%"  | ||||
|       py={{ base: 2, md: 3 }} | ||||
|       bg={colorMode === 'light' ? 'white' : 'gray.800'} | ||||
|       bg={isLightVariant ? 'white' : 'gray.800'} | ||||
|       boxShadow="sm" | ||||
|       position="sticky" | ||||
|       top={0} | ||||
|       zIndex={10} | ||||
|       key={currentTheme} // Добавляем ключ для принудительного перерендеринга при изменении темы
 | ||||
|     > | ||||
|       {/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */} | ||||
|       {serviceMenuContainerRef && ( | ||||
| @ -126,18 +134,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro | ||||
|                   {i18n.language === 'ru' ? 'EN' : 'RU'} | ||||
|                 </Button> | ||||
|                  | ||||
|                 <IconButton | ||||
|                   aria-label={colorMode === 'light'  | ||||
|                     ? t('journal.pl.theme.switchDark')  | ||||
|                     : t('journal.pl.theme.switchLight') | ||||
|                   } | ||||
|                   icon={colorMode === 'light' ? <MoonIcon boxSize={{ base: "14px" }} /> : <SunIcon boxSize={{ base: "14px" }} />} | ||||
|                   onClick={toggleColorMode} | ||||
|                   variant="ghost" | ||||
|                   size={{ base: "sm" }} | ||||
|                   minW={{ base: "30px" }} | ||||
|                   h={{ base: "30px" }} | ||||
|                 /> | ||||
|                 <ThemeSelector variant="icon" size="sm" /> | ||||
|               </HStack> | ||||
|             </Flex> | ||||
|              | ||||
| @ -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 | ||||
|                     ) : ( | ||||
|                       <Text | ||||
|                         fontWeight={crumb.isCurrentPage ? "bold" : "medium"} | ||||
|                         color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined} | ||||
|                         color={crumb.isCurrentPage ? (isLightVariant ? 'cyan.600' : 'cyan.300') : undefined} | ||||
|                         fontSize={fontSize} | ||||
|                         noOfLines={1} | ||||
|                         title={crumb.title} | ||||
| @ -226,7 +223,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro | ||||
|                         textOverflow="ellipsis" | ||||
|                         display="inline-block" | ||||
|                         fontWeight={crumb.isCurrentPage ? "bold" : "medium"} | ||||
|                         color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined} | ||||
|                         color={crumb.isCurrentPage ? (isLightVariant ? 'cyan.600' : 'cyan.300') : undefined} | ||||
|                         title={crumb.title} | ||||
|                       > | ||||
|                         {crumb.title} | ||||
| @ -254,18 +251,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro | ||||
|                 {i18n.language === 'ru' ? 'EN' : 'RU'} | ||||
|               </Button> | ||||
|                | ||||
|               <IconButton | ||||
|                 aria-label={colorMode === 'light'  | ||||
|                   ? t('journal.pl.theme.switchDark')  | ||||
|                   : t('journal.pl.theme.switchLight') | ||||
|                 } | ||||
|                 icon={colorMode === 'light' ? <MoonIcon boxSize={{ sm: "14px", md: "16px" }} /> : <SunIcon boxSize={{ sm: "14px", md: "16px" }} />} | ||||
|                 onClick={toggleColorMode} | ||||
|                 variant="ghost" | ||||
|                 size={{ sm: "sm", md: "md" }} | ||||
|                 minW={{ sm: "34px" }} | ||||
|                 h={{ sm: "34px" }} | ||||
|               /> | ||||
|               <ThemeSelector variant="icon" size="sm" /> | ||||
|             </HStack> | ||||
|           </Flex> | ||||
|         )} | ||||
|  | ||||
| @ -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<StudentCardProps> = ({ | ||||
|   onAddUser, | ||||
|   reaction | ||||
| }) => { | ||||
|   const { isLightVariant } = useThemeManager(); | ||||
|    | ||||
|   return ( | ||||
|     <motion.li | ||||
|       key={student.sub} | ||||
| @ -163,7 +165,7 @@ export const StudentCard: React.FC<StudentCardProps> = ({ | ||||
|           /> | ||||
|            | ||||
|           {/* Battery indicator in corner */} | ||||
|           <BatteryIndicator student={student} /> | ||||
|           {/* <BatteryIndicator student={student} /> */} | ||||
|         </Box> | ||||
|          | ||||
|         {/* Back side */} | ||||
|  | ||||
| @ -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<StudentCardBackProps> = ({ 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 ( | ||||
|     <Flex | ||||
|       position="absolute" | ||||
| @ -50,7 +123,7 @@ export const StudentCardBack: React.FC<StudentCardBackProps> = ({ 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<StudentCardBackProps> = ({ student, onAdd | ||||
|         </AddMissedButton> | ||||
|       )} | ||||
|        | ||||
|       {/* Веселый фон с градиентом */} | ||||
|       {/* Фон с градиентом */} | ||||
|       <Box  | ||||
|         position="absolute" | ||||
|         top="0" | ||||
|         left="0" | ||||
|         right="0" | ||||
|         bottom="0" | ||||
|         background={colors.gradient} | ||||
|         opacity="0.2" | ||||
|         background={theme.gradient} | ||||
|         opacity="0.7" | ||||
|       /> | ||||
|        | ||||
|       {/* Декоративные пузырьки */} | ||||
|       {[...Array(6)].map((_, i) => { | ||||
|         const style = getBubbleStyle(i); | ||||
|         return ( | ||||
|           <Box | ||||
|             key={i} | ||||
|             position="absolute" | ||||
|             left={style.left} | ||||
|             top={style.top} | ||||
|             width={style.size} | ||||
|             height={style.size} | ||||
|             borderRadius="full" | ||||
|             background={colors.gradient} | ||||
|             opacity="0.4" | ||||
|             sx={{ | ||||
|               animation: `float ${style.duration} ease-in-out infinite`, | ||||
|               "@keyframes float": { | ||||
|                 "0%, 100%": { transform: "translateY(0px)" }, | ||||
|                 "50%": { transform: "translateY(-10px)" } | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|         ); | ||||
|       })} | ||||
|        | ||||
|       {/* Content */} | ||||
|       <Flex  | ||||
|         direction="column" | ||||
| @ -117,7 +165,7 @@ export const StudentCardBack: React.FC<StudentCardBackProps> = ({ student, onAdd | ||||
|         width="100%" | ||||
|         zIndex="1" | ||||
|       > | ||||
|         {/* Gravatar с весёлой анимацией */} | ||||
|         {/* Аватар */} | ||||
|         <Box  | ||||
|           position="relative" | ||||
|           width="80px" | ||||
| @ -125,9 +173,8 @@ export const StudentCardBack: React.FC<StudentCardBackProps> = ({ 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 */} | ||||
|           <Box  | ||||
|             position="absolute"  | ||||
|             top="0"  | ||||
| @ -151,23 +198,22 @@ export const StudentCardBack: React.FC<StudentCardBackProps> = ({ 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} | ||||
|         </Box> | ||||
|          | ||||
|         {/* Status с эмодзи */} | ||||
|         <Box  | ||||
|           fontSize="xs"  | ||||
|           color={colors.text} | ||||
|           fontWeight="medium" | ||||
|           display="flex" | ||||
|           alignItems="center" | ||||
|         {/* Основная информация */} | ||||
|         <Text  | ||||
|           fontSize="sm"  | ||||
|           textAlign="center"  | ||||
|           color={theme.infoText} | ||||
|         > | ||||
|           <Box as="span" mr="1" fontSize="sm">🔔</Box> | ||||
|           {t('journal.pl.lesson.notMarked')} | ||||
|         </Box> | ||||
|           {isPresent  | ||||
|             ? t('journal.pl.lesson.presentToday')  | ||||
|             : t('journal.pl.lesson.notMarked')} | ||||
|         </Text> | ||||
|       </Flex> | ||||
|     </Flex> | ||||
|   ) | ||||
|  | ||||
| @ -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 ( | ||||
|         <Container maxW="container.xl"> | ||||
| @ -15,8 +16,8 @@ export const PageLoader = () => { | ||||
|                 <Spinner | ||||
|                     thickness="4px" | ||||
|                     speed="0.65s" | ||||
|                     emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'} | ||||
|                     color={colorMode === 'light' ? 'blue.500' : 'blue.300'} | ||||
|                     emptyColor={isLightVariant ? 'gray.200' : 'gray.600'} | ||||
|                     color={isLightVariant ? 'blue.500' : 'blue.300'} | ||||
|                     size="xl" | ||||
|                 /> | ||||
|             </Center> | ||||
|  | ||||
							
								
								
									
										1
									
								
								src/components/theme-selector/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/components/theme-selector/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| export { ThemeSelector } from './theme-selector';  | ||||
							
								
								
									
										133
									
								
								src/components/theme-selector/theme-selector.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								src/components/theme-selector/theme-selector.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -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 <SunIcon />; | ||||
|     case DARK_THEME: | ||||
|       return <MoonIcon />; | ||||
|     case PINK_THEME: | ||||
|       return <Box>💕</Box>; | ||||
|     case BLUE_THEME: | ||||
|       return <Box>🌊</Box>; | ||||
|     case GREEN_THEME: | ||||
|       return <Box>🌿</Box>; | ||||
|     case PURPLE_THEME: | ||||
|       return <Box>✨</Box>; | ||||
|     default: | ||||
|       return <SunIcon />; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| // Цвета фона пунктов меню для предпросмотра
 | ||||
| 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<ThemeSelectorProps> = ({  | ||||
|   variant = 'icon', | ||||
|   size = 'md' | ||||
| }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const dispatch = useDispatch(); | ||||
|   const currentTheme = useAppSelector(selectCurrentTheme); | ||||
|    | ||||
|   // Состояние для контроля открытия меню
 | ||||
|   const [isMenuOpen, setIsMenuOpen] = useState(false); | ||||
|    | ||||
|   // Обработчик клика по иконке
 | ||||
|   const handleIconClick = useCallback((e: MouseEvent<HTMLButtonElement>) => { | ||||
|     // Если нажат Ctrl, отображаем меню
 | ||||
|     if (e.ctrlKey) { | ||||
|       setIsMenuOpen(true); | ||||
|     } else { | ||||
|       // Иначе просто циклически меняем тему
 | ||||
|       dispatch(cycleNextTheme()); | ||||
|     } | ||||
|   }, [dispatch]); | ||||
|    | ||||
|   // Обработчик выбора темы из меню
 | ||||
|   const handleThemeChange = (theme: ThemeType) => { | ||||
|     dispatch(setTheme(theme)); | ||||
|     setIsMenuOpen(false); | ||||
|   }; | ||||
| 
 | ||||
|   // Рендерим одиночную иконку с меню при Ctrl+клик
 | ||||
|   return ( | ||||
|     <Menu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)}> | ||||
|       <Tooltip  | ||||
|         label={t('journal.pl.theme.select')}  | ||||
|         aria-label={t('journal.pl.theme.select')} | ||||
|       > | ||||
|         <MenuButton | ||||
|           as={IconButton} | ||||
|           aria-label={t('journal.pl.theme.select')} | ||||
|           icon={<ThemeIcon theme={currentTheme} />} | ||||
|           variant="ghost" | ||||
|           size={size} | ||||
|           onClick={handleIconClick} | ||||
|         /> | ||||
|       </Tooltip> | ||||
|       <MenuList zIndex={1000}> | ||||
|         {THEMES.map((theme) => ( | ||||
|           <MenuItem | ||||
|             key={theme} | ||||
|             onClick={() => handleThemeChange(theme)} | ||||
|             icon={<ThemeIcon theme={theme} />} | ||||
|             {...getMenuItemStyles(theme)} | ||||
|           > | ||||
|             {t(`journal.pl.theme.${theme}`)} | ||||
|           </MenuItem> | ||||
|         ))} | ||||
|       </MenuList> | ||||
|     </Menu> | ||||
|   ); | ||||
| };  | ||||
| @ -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<NodeJS.Timeout | null>(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 | ||||
|         fontSize="xs" | ||||
|         color={color} | ||||
|         position="absolute" | ||||
|         bottom="8px" | ||||
|         right="8px" | ||||
|       > | ||||
|         {text} | ||||
|       </Text> | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <Wrapper  | ||||
|       warn={!present}  | ||||
| @ -137,6 +186,19 @@ export const UserCard = ({ | ||||
|           </motion.div> | ||||
|         )} | ||||
|       </AnimatePresence> | ||||
|        | ||||
|       {/* Дополнительные декоративные элементы в зависимости от темы */} | ||||
|       <Box | ||||
|         position="absolute" | ||||
|         top={0} | ||||
|         left={0} | ||||
|         right={0} | ||||
|         height="5px" | ||||
|         background={isLightVariant ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)'} | ||||
|         borderTopLeftRadius="md" | ||||
|         borderTopRightRadius="md" | ||||
|         zIndex={1} | ||||
|       /> | ||||
|     </Wrapper> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -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<XlSpinnerProps> = ({ size = 'xl' }) => { | ||||
|     const { colorMode } = useColorMode(); | ||||
|     const { isLightVariant } = useThemeManager(); | ||||
|      | ||||
|     return ( | ||||
|         <Container maxW="container.xl"> | ||||
| @ -19,8 +20,8 @@ export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => { | ||||
|                 <Spinner | ||||
|                     thickness="4px" | ||||
|                     speed="0.65s" | ||||
|                     emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'} | ||||
|                     color={colorMode === 'light' ? 'blue.500' : 'blue.300'} | ||||
|                     emptyColor={isLightVariant ? 'gray.200' : 'gray.600'} | ||||
|                     color={isLightVariant ? 'blue.500' : 'blue.300'} | ||||
|                     size={size} | ||||
|                 /> | ||||
|             </Center> | ||||
|  | ||||
| @ -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 <AppHeader serviceMenuContainerRef={serviceMenuContainerRef} breadcrumbs={breadcrumbs} />; | ||||
| }; | ||||
| 
 | ||||
| interface DashboardProps { | ||||
|   store: any; // Используем any, поскольку точный тип store не указан
 | ||||
| } | ||||
| 
 | ||||
| export const Dashboard = ({ store }: DashboardProps) => { | ||||
| export const Dashboard = () => { | ||||
|   const serviceMenuContainerRef = useRef<HTMLDivElement>(null); | ||||
|   const serviceMenuInstanceRef = useRef<any>(null); | ||||
|   const [serviceMenu, setServiceMenu] = useState(false); | ||||
|   const { colorMode } = useColorMode(); | ||||
|   const { isLightVariant, isDarkVariant } = useThemeManager(); | ||||
|   const { t } = useTranslation(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
| @ -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'), | ||||
| @ -113,10 +110,9 @@ export const Dashboard = ({ store }: DashboardProps) => { | ||||
|         serviceMenuInstanceRef.current = null; | ||||
|       } | ||||
|     }; | ||||
|   }, [keycloak.token, serviceMenu, colorMode, t]); | ||||
|   }, [keycloak.token, serviceMenu, isLightVariant, isDarkVariant, t]); | ||||
| 
 | ||||
|   return ( | ||||
|     <Provider store={store}> | ||||
|       <BreadcrumbsProvider> | ||||
|         <HeaderWithBreadcrumbs serviceMenuContainerRef={serviceMenuContainerRef} /> | ||||
|         <Routes> | ||||
| @ -162,6 +158,5 @@ export const Dashboard = ({ store }: DashboardProps) => { | ||||
|           /> | ||||
|         </Routes> | ||||
|       </BreadcrumbsProvider> | ||||
|     </Provider> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
							
								
								
									
										132
									
								
								src/hooks/useThemeManager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/hooks/useThemeManager.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<ThemeType, 'light' | 'dark'> = { | ||||
|   [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<MutationObserver | null>(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, | ||||
|   }; | ||||
| };  | ||||
							
								
								
									
										1
									
								
								src/types/theme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/types/theme.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| export type ThemeType = 'light' | 'dark' | 'pink' | 'blue' | 'green' | 'purple';  | ||||
							
								
								
									
										175
									
								
								src/user-card/user-card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								src/user-card/user-card.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -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<UserCardProps> = ({ | ||||
|   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 | ||||
|         fontSize="xs" | ||||
|         color={color} | ||||
|         position="absolute" | ||||
|         bottom="8px" | ||||
|         right="8px" | ||||
|       > | ||||
|         {text} | ||||
|       </Text> | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <Box | ||||
|       position="relative" | ||||
|       borderRadius="md" | ||||
|       overflow="hidden" | ||||
|       boxShadow="sm" | ||||
|       transition="transform 0.2s, box-shadow 0.2s" | ||||
|       _hover={onClick ? { | ||||
|         transform: 'translateY(-2px)', | ||||
|         boxShadow: 'md', | ||||
|         cursor: 'pointer' | ||||
|       } : {}} | ||||
|       onClick={onClick} | ||||
|       bg={isLightVariant ? 'white' : 'gray.700'} | ||||
|       p={3} | ||||
|     > | ||||
|       <Box | ||||
|         position="relative" | ||||
|         borderRadius="full" | ||||
|         width="60px" | ||||
|         height="60px" | ||||
|         overflow="hidden" | ||||
|         margin="0 auto 8px" | ||||
|       > | ||||
|         {user.photo ? ( | ||||
|           <Box | ||||
|             as="img" | ||||
|             src={user.photo} | ||||
|             alt={user.name} | ||||
|             width="100%" | ||||
|             height="100%" | ||||
|             objectFit="cover" | ||||
|           /> | ||||
|         ) : ( | ||||
|           <Box | ||||
|             width="100%" | ||||
|             height="100%" | ||||
|             bg="gray.300" | ||||
|             display="flex" | ||||
|             alignItems="center" | ||||
|             justifyContent="center" | ||||
|             fontSize="xl" | ||||
|             fontWeight="bold" | ||||
|             color="gray.600" | ||||
|           > | ||||
|             {user.name.charAt(0).toUpperCase()} | ||||
|           </Box> | ||||
|         )} | ||||
|          | ||||
|         {showStatus && user.status && ( | ||||
|           <Box | ||||
|             position="absolute" | ||||
|             bottom="2px" | ||||
|             right="2px" | ||||
|             width="12px" | ||||
|             height="12px" | ||||
|             borderRadius="full" | ||||
|             bg={getStatusColor(user.status)} | ||||
|             border="2px solid" | ||||
|             borderColor={isLightVariant ? 'white' : 'gray.700'} | ||||
|           /> | ||||
|         )} | ||||
|       </Box> | ||||
|        | ||||
|       <Text | ||||
|         textAlign="center" | ||||
|         fontWeight="medium" | ||||
|         fontSize="sm" | ||||
|         noOfLines={1} | ||||
|         title={user.name} | ||||
|       > | ||||
|         {user.name} | ||||
|       </Text> | ||||
|        | ||||
|       {user.role && ( | ||||
|         <Text | ||||
|           textAlign="center" | ||||
|           fontSize="xs" | ||||
|           color={isLightVariant ? 'gray.500' : 'gray.300'} | ||||
|           noOfLines={1} | ||||
|         > | ||||
|           {user.role} | ||||
|         </Text> | ||||
|       )} | ||||
|        | ||||
|       {showAttendance && typeof user.attendance === 'number' && renderAttendanceIndicator(user.attendance)} | ||||
|        | ||||
|       {/* Дополнительные декоративные элементы в зависимости от темы */} | ||||
|       <Box | ||||
|         position="absolute" | ||||
|         top={0} | ||||
|         left={0} | ||||
|         right={0} | ||||
|         height="5px" | ||||
|         background={isLightVariant ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)'} | ||||
|         borderTopLeftRadius="md" | ||||
|         borderTopRightRadius="md" | ||||
|         zIndex={1} | ||||
|       /> | ||||
|     </Box> | ||||
|   ); | ||||
| };  | ||||
							
								
								
									
										105
									
								
								src/utils/theme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/utils/theme.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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, | ||||
|         }, | ||||
|       }), | ||||
|     }, | ||||
|   }, | ||||
| });  | ||||
							
								
								
									
										45
									
								
								src/utils/themes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/utils/themes.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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'; | ||||
|   } | ||||
| };  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user