Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 183e3826be | |||
| 1ec4bc081e |
@@ -44,11 +44,6 @@ module.exports = {
|
|||||||
value: '',
|
value: '',
|
||||||
key: 'courses.statistics',
|
key: 'courses.statistics',
|
||||||
},
|
},
|
||||||
'courceNameSuggestion': {
|
|
||||||
on: true,
|
|
||||||
value: '',
|
|
||||||
key: 'courceNameSuggestion',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -93,14 +93,6 @@
|
|||||||
"journal.pl.lesson.noStudents": "No Students Yet",
|
"journal.pl.lesson.noStudents": "No Students Yet",
|
||||||
"journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here",
|
"journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here",
|
||||||
"journal.pl.lesson.notMarked": "Not yet marked",
|
"journal.pl.lesson.notMarked": "Not yet marked",
|
||||||
"journal.pl.lesson.attendance": "Attendance",
|
|
||||||
"journal.pl.lesson.recentlyJoined": "Recently joined",
|
|
||||||
"journal.pl.lesson.veryPoorAttendance": "Very poor attendance",
|
|
||||||
"journal.pl.lesson.poorAttendance": "Poor attendance",
|
|
||||||
"journal.pl.lesson.lowAttendance": "Low attendance",
|
|
||||||
"journal.pl.lesson.mediumAttendance": "Medium attendance",
|
|
||||||
"journal.pl.lesson.goodAttendance": "Good attendance",
|
|
||||||
"journal.pl.lesson.excellentAttendance": "Excellent attendance",
|
|
||||||
|
|
||||||
"journal.pl.reactions.thumbs_up": "Thumbs up",
|
"journal.pl.reactions.thumbs_up": "Thumbs up",
|
||||||
"journal.pl.reactions.heart": "Heart",
|
"journal.pl.reactions.heart": "Heart",
|
||||||
@@ -157,13 +149,6 @@
|
|||||||
|
|
||||||
"journal.pl.theme.switchDark": "Switch to dark theme",
|
"journal.pl.theme.switchDark": "Switch to dark theme",
|
||||||
"journal.pl.theme.switchLight": "Switch to light 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.switchToEn": "Switch to English",
|
||||||
"journal.pl.lang.switchToRu": "Switch to Russian",
|
"journal.pl.lang.switchToRu": "Switch to Russian",
|
||||||
|
|||||||
@@ -90,14 +90,6 @@
|
|||||||
"journal.pl.lesson.noStudents": "Пока нет студентов",
|
"journal.pl.lesson.noStudents": "Пока нет студентов",
|
||||||
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
|
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
|
||||||
"journal.pl.lesson.notMarked": "Не отмечен",
|
"journal.pl.lesson.notMarked": "Не отмечен",
|
||||||
"journal.pl.lesson.attendance": "Посещаемость",
|
|
||||||
"journal.pl.lesson.recentlyJoined": "Недавно присоединился",
|
|
||||||
"journal.pl.lesson.veryPoorAttendance": "В первый раз?",
|
|
||||||
"journal.pl.lesson.poorAttendance": "Не узнаю",
|
|
||||||
"journal.pl.lesson.lowAttendance": "Редкий гость",
|
|
||||||
"journal.pl.lesson.mediumAttendance": "Бывает заходит",
|
|
||||||
"journal.pl.lesson.goodAttendance": "Часто заходит",
|
|
||||||
"journal.pl.lesson.excellentAttendance": "Часто заходит и не уходит",
|
|
||||||
|
|
||||||
"journal.pl.reactions.thumbs_up": "Палец вверх",
|
"journal.pl.reactions.thumbs_up": "Палец вверх",
|
||||||
"journal.pl.reactions.heart": "Сердце",
|
"journal.pl.reactions.heart": "Сердце",
|
||||||
@@ -154,13 +146,6 @@
|
|||||||
|
|
||||||
"journal.pl.theme.switchDark": "Переключить на темную тему",
|
"journal.pl.theme.switchDark": "Переключить на темную тему",
|
||||||
"journal.pl.theme.switchLight": "Переключить на светлую тему",
|
"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.switchToEn": "Переключить на английский",
|
||||||
"journal.pl.lang.switchToRu": "Переключить на русский",
|
"journal.pl.lang.switchToRu": "Переключить на русский",
|
||||||
|
|||||||
909
package-lock.json
generated
909
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "journal.pl",
|
"name": "journal.pl",
|
||||||
"version": "3.18.2",
|
"version": "3.16.5",
|
||||||
"description": "bro-js platform journal ui repo",
|
"description": "bro-js platform journal ui repo",
|
||||||
"main": "./src/index.tsx",
|
"main": "./src/index.tsx",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,14 +73,6 @@ export const api = createApi({
|
|||||||
query: (courseId) => `/lesson/${courseId}/ai/generate-lessons`,
|
query: (courseId) => `/lesson/${courseId}/ai/generate-lessons`,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
generateLessonName: builder.mutation<BaseResponse<{ name: string }[]>, { courseId: string, name: string }>({
|
|
||||||
query: ({ courseId, name }) => ({
|
|
||||||
url: `/lesson/${courseId}/ai/generate-lesson-name`,
|
|
||||||
method: 'POST',
|
|
||||||
body: { name },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
|
|
||||||
createLesson: builder.mutation<
|
createLesson: builder.mutation<
|
||||||
BaseResponse<Lesson>,
|
BaseResponse<Lesson>,
|
||||||
Partial<Lesson> & Pick<Lesson, 'name' | 'date'> & { courseId: string }
|
Partial<Lesson> & Pick<Lesson, 'name' | 'date'> & { courseId: string }
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
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,7 +3,6 @@ import { TypedUseSelectorHook, useSelector } from 'react-redux'
|
|||||||
|
|
||||||
import { api } from './api/api'
|
import { api } from './api/api'
|
||||||
import { userSlice } from './slices/user'
|
import { userSlice } from './slices/user'
|
||||||
import { themeSlice } from './slices/theme'
|
|
||||||
|
|
||||||
export const createStore = (preloadedState = {}) =>
|
export const createStore = (preloadedState = {}) =>
|
||||||
configureStore({
|
configureStore({
|
||||||
@@ -11,7 +10,6 @@ export const createStore = (preloadedState = {}) =>
|
|||||||
reducer: {
|
reducer: {
|
||||||
[api.reducerPath]: api.reducer,
|
[api.reducerPath]: api.reducer,
|
||||||
[userSlice.name]: userSlice.reducer,
|
[userSlice.name]: userSlice.reducer,
|
||||||
[themeSlice.name]: themeSlice.reducer,
|
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
|
|||||||
20
src/app.tsx
20
src/app.tsx
@@ -4,12 +4,18 @@ import { Global } from '@emotion/react'
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import dayjs from './utils/dayjs-config';
|
import dayjs from './utils/dayjs-config';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react'
|
import { ChakraProvider, ColorModeScript, extendTheme } from '@chakra-ui/react'
|
||||||
import { Provider } from 'react-redux'
|
|
||||||
|
|
||||||
import { Dashboard } from './dashboard';
|
import { Dashboard } from './dashboard';
|
||||||
import { globalStyles } from './global.styles';
|
import { globalStyles } from './global.styles';
|
||||||
import { chakraTheme } from './utils/theme';
|
|
||||||
|
// Расширяем тему Chakra UI
|
||||||
|
const theme = extendTheme({
|
||||||
|
config: {
|
||||||
|
initialColorMode: 'light',
|
||||||
|
useSystemColorMode: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
store: any; // Тип для store зависит от конкретной реализации хранилища
|
store: any; // Тип для store зависит от конкретной реализации хранилища
|
||||||
@@ -18,19 +24,17 @@ interface AppProps {
|
|||||||
const App: React.FC<AppProps> = ({ store }) => {
|
const App: React.FC<AppProps> = ({ store }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<ChakraProvider theme={theme}>
|
||||||
<ChakraProvider theme={chakraTheme}>
|
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
|
||||||
<ColorModeScript initialColorMode={chakraTheme.config.initialColorMode} />
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||||
<title>{t('journal.pl.title')}</title>
|
<title>{t('journal.pl.title')}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Global styles={globalStyles} />
|
<Global styles={globalStyles} />
|
||||||
<Dashboard />
|
<Dashboard store={store} />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ChakraProvider>
|
</ChakraProvider>
|
||||||
</Provider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ import { MoonIcon, SunIcon, ChevronRightIcon, InfoIcon, ChevronDownIcon } from '
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { getNavigationValue } from '@brojs/cli';
|
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 {
|
interface AppHeaderProps {
|
||||||
serviceMenuContainerRef?: React.RefObject<HTMLDivElement>;
|
serviceMenuContainerRef?: React.RefObject<HTMLDivElement>;
|
||||||
@@ -36,9 +32,7 @@ interface AppHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderProps) => {
|
export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderProps) => {
|
||||||
const { isLightVariant, isDarkVariant } = useThemeManager();
|
const { colorMode, toggleColorMode } = useColorMode();
|
||||||
// Используем напрямую селектор из Redux для получения темы и для передачи в key
|
|
||||||
const currentTheme = useAppSelector(selectCurrentTheme);
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -61,11 +55,11 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Определяем размеры для разных устройств
|
// Определяем размеры для разных устройств
|
||||||
const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' }, { ssr: false });
|
const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' });
|
||||||
|
|
||||||
// Проверяем, на каком устройстве находимся
|
// Проверяем, на каком устройстве находимся
|
||||||
const [isLargerThan768] = useMediaQuery("(min-width: 768px)", { ssr: false });
|
const [isLargerThan768] = useMediaQuery("(min-width: 768px)");
|
||||||
const [isLargerThan480] = useMediaQuery("(min-width: 480px)", { ssr: false });
|
const [isLargerThan480] = useMediaQuery("(min-width: 480px)");
|
||||||
|
|
||||||
// Вертикальное отображение на мобильных устройствах
|
// Вертикальное отображение на мобильных устройствах
|
||||||
const isMobile = !isLargerThan480;
|
const isMobile = !isLargerThan480;
|
||||||
@@ -74,25 +68,23 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
|
|||||||
const horizontalSeparator = useBreakpointValue({
|
const horizontalSeparator = useBreakpointValue({
|
||||||
sm: <ChevronRightIcon color="gray.400" fontSize="xs" />,
|
sm: <ChevronRightIcon color="gray.400" fontSize="xs" />,
|
||||||
md: <ChevronRightIcon color="gray.400" />
|
md: <ChevronRightIcon color="gray.400" />
|
||||||
}, { ssr: false });
|
});
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
const toggleLanguage = () => {
|
||||||
const newLang = i18n.language === 'ru' ? 'en' : 'ru';
|
const newLang = i18n.language === 'ru' ? 'en' : 'ru';
|
||||||
i18n.changeLanguage(newLang);
|
i18n.changeLanguage(newLang);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Используем key={currentTheme} для принудительного перерендеринга при изменении темы
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="header"
|
as="header"
|
||||||
width="100%"
|
width="100%"
|
||||||
py={{ base: 2, md: 3 }}
|
py={{ base: 2, md: 3 }}
|
||||||
bg={isLightVariant ? 'white' : 'gray.800'}
|
bg={colorMode === 'light' ? 'white' : 'gray.800'}
|
||||||
boxShadow="sm"
|
boxShadow="sm"
|
||||||
position="sticky"
|
position="sticky"
|
||||||
top={0}
|
top={0}
|
||||||
zIndex={10}
|
zIndex={10}
|
||||||
key={currentTheme} // Добавляем ключ для принудительного перерендеринга при изменении темы
|
|
||||||
>
|
>
|
||||||
{/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */}
|
{/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */}
|
||||||
{serviceMenuContainerRef && (
|
{serviceMenuContainerRef && (
|
||||||
@@ -134,7 +126,18 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
|
|||||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ThemeSelector variant="icon" size="sm" />
|
<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" }}
|
||||||
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
|
|||||||
py={1}
|
py={1}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={!crumb.isCurrentPage && crumb.path ? {
|
_hover={!crumb.isCurrentPage && crumb.path ? {
|
||||||
bg: isLightVariant ? 'gray.50' : 'gray.700',
|
bg: colorMode === 'light' ? 'gray.50' : 'gray.700',
|
||||||
} : {}}
|
} : {}}
|
||||||
>
|
>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
@@ -181,7 +184,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
||||||
color={crumb.isCurrentPage ? (isLightVariant ? 'cyan.600' : 'cyan.300') : undefined}
|
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
noOfLines={1}
|
noOfLines={1}
|
||||||
title={crumb.title}
|
title={crumb.title}
|
||||||
@@ -223,7 +226,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
|
|||||||
textOverflow="ellipsis"
|
textOverflow="ellipsis"
|
||||||
display="inline-block"
|
display="inline-block"
|
||||||
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
||||||
color={crumb.isCurrentPage ? (isLightVariant ? 'cyan.600' : 'cyan.300') : undefined}
|
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
|
||||||
title={crumb.title}
|
title={crumb.title}
|
||||||
>
|
>
|
||||||
{crumb.title}
|
{crumb.title}
|
||||||
@@ -251,7 +254,18 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
|
|||||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ThemeSelector variant="icon" size="sm" />
|
<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" }}
|
||||||
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Box } from '@chakra-ui/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { formatDate } from '../../utils/dayjs-config'
|
|
||||||
import { getAttendanceColor } from '../../utils/attendance-colors'
|
|
||||||
|
|
||||||
interface AttendanceStatsProps {
|
|
||||||
lessonDate: string
|
|
||||||
studentsPresent: number
|
|
||||||
totalStudents: number
|
|
||||||
isLoading: boolean
|
|
||||||
isPulsing: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AttendanceStats: React.FC<AttendanceStatsProps> = ({
|
|
||||||
lessonDate,
|
|
||||||
studentsPresent,
|
|
||||||
totalStudents,
|
|
||||||
isLoading,
|
|
||||||
isPulsing
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const attendanceColors = getAttendanceColor(studentsPresent, totalStudents)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box pb={3}>
|
|
||||||
{formatDate(lessonDate, t('journal.pl.lesson.dateFormat'))}{' '}
|
|
||||||
{t('journal.pl.common.marked')} -
|
|
||||||
{!isLoading && (
|
|
||||||
<Box
|
|
||||||
as="span"
|
|
||||||
px={2}
|
|
||||||
py={1}
|
|
||||||
ml={2}
|
|
||||||
borderRadius="md"
|
|
||||||
fontWeight="bold"
|
|
||||||
bg={attendanceColors.bg}
|
|
||||||
color={attendanceColors.color}
|
|
||||||
_dark={{
|
|
||||||
bg: attendanceColors.dark.bg,
|
|
||||||
color: attendanceColors.dark.color
|
|
||||||
}}
|
|
||||||
position="relative"
|
|
||||||
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
|
|
||||||
sx={{
|
|
||||||
'@keyframes pulse': {
|
|
||||||
'0%': { transform: 'scale(1)' },
|
|
||||||
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
|
|
||||||
'100%': { transform: 'scale(1)' }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{studentsPresent} / {totalStudents}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{isLoading && (
|
|
||||||
<span> {studentsPresent}</span>
|
|
||||||
)}{' '}
|
|
||||||
{t('journal.pl.common.people')}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import React, { useRef } from 'react'
|
|
||||||
|
|
||||||
import { QRCanvas } from '../../pages/style'
|
|
||||||
import { useQRCode } from '../../hooks/useQRCode'
|
|
||||||
|
|
||||||
interface QRCodeDisplayProps {
|
|
||||||
url: string
|
|
||||||
isFetching: boolean
|
|
||||||
isSuccess: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QRCodeDisplay: React.FC<QRCodeDisplayProps> = ({
|
|
||||||
url,
|
|
||||||
isFetching,
|
|
||||||
isSuccess
|
|
||||||
}) => {
|
|
||||||
const canvRef = useRef(null)
|
|
||||||
|
|
||||||
// Use the QR code hook
|
|
||||||
useQRCode(canvRef, url, isFetching, isSuccess)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={url}>
|
|
||||||
<QRCanvas ref={canvRef} />
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Box } from '@chakra-ui/react'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { User, Reaction } from '../../__data__/model'
|
|
||||||
import { UserCard } from '../user-card'
|
|
||||||
import { StudentCardBack } from './StudentCardBack'
|
|
||||||
import { useThemeManager } from '../../hooks/useThemeManager'
|
|
||||||
|
|
||||||
// Компонент маленькой батарейки для отображения в углу карточки
|
|
||||||
const BatteryIndicator: React.FC<{
|
|
||||||
student: User & { present?: boolean; recentlyPresent?: boolean }
|
|
||||||
}> = ({ student }) => {
|
|
||||||
const { isLightVariant, isDarkVariant } = useThemeManager();
|
|
||||||
|
|
||||||
// Та же логика из StudentCardBack для определения уровня батареи
|
|
||||||
const getAttendanceLevel = () => {
|
|
||||||
if (student.present) {
|
|
||||||
return student.recentlyPresent ? 4 : 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = student.sub || '';
|
|
||||||
const idSum = id.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
|
||||||
return Math.min(3, Math.floor((idSum % 100) / 25));
|
|
||||||
}
|
|
||||||
|
|
||||||
const batteryLevel = getAttendanceLevel();
|
|
||||||
|
|
||||||
// Цвета для разных уровней заряда батареи
|
|
||||||
const colors = [
|
|
||||||
// Empty (0)
|
|
||||||
{ primary: isLightVariant ? "#E53E3E" : "#F56565" },
|
|
||||||
// Very Low (1)
|
|
||||||
{ primary: isLightVariant ? "#DD6B20" : "#ED8936" },
|
|
||||||
// Low (2)
|
|
||||||
{ primary: isLightVariant ? "#D69E2E" : "#ECC94B" },
|
|
||||||
// Medium (3)
|
|
||||||
{ primary: isLightVariant ? "#38B2AC" : "#4FD1C5" },
|
|
||||||
// Good (4)
|
|
||||||
{ primary: isLightVariant ? "#3182CE" : "#4299E1" },
|
|
||||||
// Excellent (5)
|
|
||||||
{ primary: isLightVariant ? "#38A169" : "#48BB78" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const color = colors[batteryLevel].primary;
|
|
||||||
|
|
||||||
// Функция для определения заполненности сегментов
|
|
||||||
const getSegmentFill = (segmentIndex: number) => {
|
|
||||||
return segmentIndex <= batteryLevel ? color : 'transparent';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="8px"
|
|
||||||
right="8px"
|
|
||||||
width="20px"
|
|
||||||
height="10px"
|
|
||||||
zIndex="1"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{/* Battery outline */}
|
|
||||||
<rect
|
|
||||||
x="2"
|
|
||||||
y="6"
|
|
||||||
width="18"
|
|
||||||
height="12"
|
|
||||||
rx="2"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
fill="transparent"
|
|
||||||
/>
|
|
||||||
{/* Battery cap */}
|
|
||||||
<path
|
|
||||||
d="M20 10H22V14H20V10Z"
|
|
||||||
fill={color}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Battery segments */}
|
|
||||||
<rect x="4" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(0)} />
|
|
||||||
<rect x="7" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(1)} />
|
|
||||||
<rect x="10" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(2)} />
|
|
||||||
<rect x="13" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(3)} />
|
|
||||||
<rect x="16" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(4)} />
|
|
||||||
|
|
||||||
{/* Lightning icon if recently joined or fully charged */}
|
|
||||||
{(student.recentlyPresent || batteryLevel === 5) && (
|
|
||||||
<path
|
|
||||||
d="M11.5 7.5L8 12H11L9.5 16.5L13 12H10L11.5 7.5Z"
|
|
||||||
fill={color}
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="0.3"
|
|
||||||
opacity="0.9"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StudentCardProps {
|
|
||||||
student: User & { present?: boolean; recentlyPresent?: boolean }
|
|
||||||
onAddUser: (user: User) => void
|
|
||||||
reaction?: Reaction
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StudentCard: React.FC<StudentCardProps> = ({
|
|
||||||
student,
|
|
||||||
onAddUser,
|
|
||||||
reaction
|
|
||||||
}) => {
|
|
||||||
const { isLightVariant } = useThemeManager();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.li
|
|
||||||
key={student.sub}
|
|
||||||
animate={{
|
|
||||||
rotateY: student.present ? 0 : 180,
|
|
||||||
boxShadow: student.recentlyPresent
|
|
||||||
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
|
|
||||||
: '0 0 0 0 rgba(0, 0, 0, 0)'
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
rotateY: { type: "spring", stiffness: 300, damping: 20 },
|
|
||||||
boxShadow: {
|
|
||||||
repeat: student.recentlyPresent ? 3 : 0,
|
|
||||||
duration: 1.5
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
transformStyle: "preserve-3d",
|
|
||||||
perspective: "1000px",
|
|
||||||
aspectRatio: "1",
|
|
||||||
width: "100%",
|
|
||||||
display: "block"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Container for 3D effect */}
|
|
||||||
<Box
|
|
||||||
position="relative"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
style={{
|
|
||||||
transformStyle: "preserve-3d"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Front side - visible when present */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="0"
|
|
||||||
left="0"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
style={{
|
|
||||||
backfaceVisibility: "hidden",
|
|
||||||
transform: "rotateY(0deg)",
|
|
||||||
zIndex: student.present ? 1 : 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserCard
|
|
||||||
wrapperAS="div"
|
|
||||||
student={student}
|
|
||||||
present={student.present}
|
|
||||||
recentlyPresent={student.recentlyPresent}
|
|
||||||
reaction={reaction}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Battery indicator in corner */}
|
|
||||||
{/* <BatteryIndicator student={student} /> */}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Back side */}
|
|
||||||
<StudentCardBack onAddUser={onAddUser} student={student} />
|
|
||||||
</Box>
|
|
||||||
</motion.li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import React from '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 }
|
|
||||||
onAddUser?: (user: User) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StudentCardBack: React.FC<StudentCardBackProps> = ({ student, onAddUser }) => {
|
|
||||||
const { colorMode } = useColorMode()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { isLightVariant, currentTheme } = useThemeManager()
|
|
||||||
|
|
||||||
// Is the student marked as present?
|
|
||||||
const isPresent = !!student.present;
|
|
||||||
|
|
||||||
// Темы для карточки
|
|
||||||
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"
|
|
||||||
top="0"
|
|
||||||
left="0"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
bg={theme.background}
|
|
||||||
borderRadius="12px"
|
|
||||||
align="center"
|
|
||||||
justify="center"
|
|
||||||
p={4}
|
|
||||||
overflow="hidden"
|
|
||||||
style={{
|
|
||||||
backfaceVisibility: "hidden",
|
|
||||||
transform: "rotateY(180deg)",
|
|
||||||
zIndex: 0,
|
|
||||||
aspectRatio: "1"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Add button */}
|
|
||||||
{!isPresent && (
|
|
||||||
<AddMissedButton
|
|
||||||
onClick={() => onAddUser?.(student)}
|
|
||||||
aria-label={t('journal.pl.common.add')}
|
|
||||||
>
|
|
||||||
<AddIcon boxSize={3} />
|
|
||||||
</AddMissedButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Фон с градиентом */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="0"
|
|
||||||
left="0"
|
|
||||||
right="0"
|
|
||||||
bottom="0"
|
|
||||||
background={theme.gradient}
|
|
||||||
opacity="0.7"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<Flex
|
|
||||||
direction="column"
|
|
||||||
align="center"
|
|
||||||
justify="center"
|
|
||||||
width="100%"
|
|
||||||
zIndex="1"
|
|
||||||
>
|
|
||||||
{/* Аватар */}
|
|
||||||
<Box
|
|
||||||
position="relative"
|
|
||||||
width="80px"
|
|
||||||
height="80px"
|
|
||||||
borderRadius="full"
|
|
||||||
overflow="hidden"
|
|
||||||
mb={4}
|
|
||||||
boxShadow={`0 0 15px ${theme.shadowColor}`}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="0"
|
|
||||||
left="0"
|
|
||||||
right="0"
|
|
||||||
bottom="0"
|
|
||||||
zIndex="1"
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
<UserCard
|
|
||||||
wrapperAS="div"
|
|
||||||
student={student}
|
|
||||||
present={false}
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Student name */}
|
|
||||||
<Box
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight="medium"
|
|
||||||
textAlign="center"
|
|
||||||
color={theme.text}
|
|
||||||
mb={3}
|
|
||||||
>
|
|
||||||
{student.name || student.preferred_username}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Основная информация */}
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
textAlign="center"
|
|
||||||
color={theme.infoText}
|
|
||||||
>
|
|
||||||
{isPresent
|
|
||||||
? t('journal.pl.lesson.presentToday')
|
|
||||||
: t('journal.pl.lesson.notMarked')}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { AnimatePresence } from 'framer-motion'
|
|
||||||
import { User, Reaction } from '../../__data__/model'
|
|
||||||
import { StudentList as StyledStudentList } from '../../pages/style'
|
|
||||||
import { StudentCard } from './StudentCard'
|
|
||||||
|
|
||||||
interface StudentListProps {
|
|
||||||
students: (User & { present?: boolean; recentlyPresent?: boolean })[]
|
|
||||||
onAddUser: (user: User) => void
|
|
||||||
studentReactions: Record<string, Reaction[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StudentList: React.FC<StudentListProps> = ({
|
|
||||||
students,
|
|
||||||
onAddUser,
|
|
||||||
studentReactions
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<StyledStudentList>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{students.map((student) => (
|
|
||||||
<StudentCard
|
|
||||||
key={student.sub}
|
|
||||||
student={student}
|
|
||||||
onAddUser={onAddUser}
|
|
||||||
reaction={studentReactions?.[student.sub]?.[0]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</StyledStudentList>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import styled from '@emotion/styled'
|
|
||||||
|
|
||||||
export const AddMissedButton = styled.button`
|
|
||||||
position: absolute;
|
|
||||||
bottom: 8px;
|
|
||||||
right: 8px;
|
|
||||||
border: none;
|
|
||||||
background-color: var(--chakra-colors-blue-500);
|
|
||||||
color: white;
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 2;
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chakra-ui-dark & {
|
|
||||||
background-color: var(--chakra-colors-blue-400);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
@@ -5,10 +5,9 @@ import {
|
|||||||
Center,
|
Center,
|
||||||
useColorMode
|
useColorMode
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useThemeManager } from '../../hooks/useThemeManager';
|
|
||||||
|
|
||||||
export const PageLoader = () => {
|
export const PageLoader = () => {
|
||||||
const { isLightVariant } = useThemeManager();
|
const { colorMode } = useColorMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="container.xl">
|
<Container maxW="container.xl">
|
||||||
@@ -16,8 +15,8 @@ export const PageLoader = () => {
|
|||||||
<Spinner
|
<Spinner
|
||||||
thickness="4px"
|
thickness="4px"
|
||||||
speed="0.65s"
|
speed="0.65s"
|
||||||
emptyColor={isLightVariant ? 'gray.200' : 'gray.600'}
|
emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'}
|
||||||
color={isLightVariant ? 'blue.500' : 'blue.300'}
|
color={colorMode === 'light' ? 'blue.500' : 'blue.300'}
|
||||||
size="xl"
|
size="xl"
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { ThemeSelector } from './theme-selector';
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -109,7 +109,8 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number; pos
|
|||||||
props.warn
|
props.warn
|
||||||
? css`
|
? css`
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
filter: grayscale(0.3);
|
filter: grayscale(0.8);
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React from 'react'
|
||||||
import { sha256 } from 'js-sha256'
|
import { sha256 } from 'js-sha256'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Box, useColorMode, Text } from '@chakra-ui/react'
|
import { Box, useColorMode, Text } from '@chakra-ui/react'
|
||||||
import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons'
|
import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useThemeManager } from '../../hooks/useThemeManager'
|
|
||||||
|
|
||||||
import { Reaction, User } from '../../__data__/model'
|
import { Reaction, User } from '../../__data__/model'
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@ const REACTION_EMOJIS = {
|
|||||||
clap: '👏'
|
clap: '👏'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGravatarURL(email) {
|
export function getGravatarURL(email, user) {
|
||||||
if (!email) return void 0
|
if (!email) return void 0
|
||||||
const address = String(email).trim().toLowerCase()
|
const address = String(email).trim().toLowerCase()
|
||||||
const hash = sha256(address)
|
const hash = sha256(address)
|
||||||
@@ -48,9 +47,6 @@ export const UserCard = ({
|
|||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [showReaction, setShowReaction] = useState(false);
|
const [showReaction, setShowReaction] = useState(false);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isLightVariant } = useThemeManager();
|
|
||||||
|
|
||||||
const randomGravatarPath = useMemo(() => Math.random() * 1000, [])
|
|
||||||
|
|
||||||
// Обрабатываем изменение реакции
|
// Обрабатываем изменение реакции
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,53 +72,6 @@ export const UserCard = ({
|
|||||||
};
|
};
|
||||||
}, [reaction]);
|
}, [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 (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
warn={!present}
|
warn={!present}
|
||||||
@@ -132,7 +81,7 @@ export const UserCard = ({
|
|||||||
position="relative"
|
position="relative"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={imageError ? getGravatarURL(student.email || randomGravatarPath) : (student.picture || getGravatarURL(student.email))}
|
src={imageError ? getGravatarURL(student.email, null) : (student.picture || getGravatarURL(student.email, null))}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
if (!imageError && student.picture) {
|
if (!imageError && student.picture) {
|
||||||
setImageError(true);
|
setImageError(true);
|
||||||
@@ -148,6 +97,7 @@ export const UserCard = ({
|
|||||||
)}
|
)}
|
||||||
</NameOverlay>
|
</NameOverlay>
|
||||||
|
|
||||||
|
|
||||||
{/* Анимация реакции */}
|
{/* Анимация реакции */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showReaction && reaction && (
|
{showReaction && reaction && (
|
||||||
@@ -186,19 +136,6 @@ export const UserCard = ({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
useColorMode
|
useColorMode
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useThemeManager } from '../../hooks/useThemeManager'
|
|
||||||
|
|
||||||
interface XlSpinnerProps {
|
interface XlSpinnerProps {
|
||||||
size?: string;
|
size?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => {
|
export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => {
|
||||||
const { isLightVariant } = useThemeManager();
|
const { colorMode } = useColorMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="container.xl">
|
<Container maxW="container.xl">
|
||||||
@@ -20,8 +19,8 @@ export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => {
|
|||||||
<Spinner
|
<Spinner
|
||||||
thickness="4px"
|
thickness="4px"
|
||||||
speed="0.65s"
|
speed="0.65s"
|
||||||
emptyColor={isLightVariant ? 'gray.200' : 'gray.600'}
|
emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'}
|
||||||
color={isLightVariant ? 'blue.500' : 'blue.300'}
|
color={colorMode === 'light' ? 'blue.500' : 'blue.300'}
|
||||||
size={size}
|
size={size}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect, Suspense, useRef, useState } from 'react'
|
import React, { useEffect, Suspense, useRef, useState } from 'react'
|
||||||
import { Routes, Route, useNavigate } from 'react-router-dom'
|
import { Routes, Route, useNavigate } from 'react-router-dom'
|
||||||
|
import { Provider } from 'react-redux'
|
||||||
import { getNavigationValue } from '@brojs/cli'
|
import { getNavigationValue } from '@brojs/cli'
|
||||||
import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react'
|
import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useThemeManager } from './hooks/useThemeManager'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CourseListPage,
|
CourseListPage,
|
||||||
@@ -68,12 +68,15 @@ const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContain
|
|||||||
return <AppHeader serviceMenuContainerRef={serviceMenuContainerRef} breadcrumbs={breadcrumbs} />;
|
return <AppHeader serviceMenuContainerRef={serviceMenuContainerRef} breadcrumbs={breadcrumbs} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
store: any; // Используем any, поскольку точный тип store не указан
|
||||||
|
}
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = ({ store }: DashboardProps) => {
|
||||||
const serviceMenuContainerRef = useRef<HTMLDivElement>(null);
|
const serviceMenuContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const serviceMenuInstanceRef = useRef<any>(null);
|
const serviceMenuInstanceRef = useRef<any>(null);
|
||||||
const [serviceMenu, setServiceMenu] = useState(false);
|
const [serviceMenu, setServiceMenu] = useState(false);
|
||||||
const { isLightVariant, isDarkVariant } = useThemeManager();
|
const { colorMode } = useColorMode();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,7 +84,7 @@ export const Dashboard = () => {
|
|||||||
setServiceMenu(true)
|
setServiceMenu(true)
|
||||||
}).catch(console.error)
|
}).catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Проверяем, что библиотека загружена и есть контейнер для меню
|
// Проверяем, что библиотека загружена и есть контейнер для меню
|
||||||
if (window.createServiceMenu && serviceMenuContainerRef.current && serviceMenu) {
|
if (window.createServiceMenu && serviceMenuContainerRef.current && serviceMenu) {
|
||||||
@@ -91,10 +94,10 @@ export const Dashboard = () => {
|
|||||||
apiUrl: 'https://admin.bro-js.ru',
|
apiUrl: 'https://admin.bro-js.ru',
|
||||||
targetElement: serviceMenuContainerRef.current,
|
targetElement: serviceMenuContainerRef.current,
|
||||||
styles: {
|
styles: {
|
||||||
dotColor: isLightVariant ? '#333' : '#ccc',
|
dotColor: colorMode === 'light' ? '#333' : '#ccc',
|
||||||
hoverColor: isLightVariant ? '#eee' : '#444',
|
hoverColor: colorMode === 'light' ? '#eee' : '#444',
|
||||||
backgroundColor: isLightVariant ? '#fff' : '#2D3748',
|
backgroundColor: colorMode === 'light' ? '#fff' : '#2D3748',
|
||||||
textColor: isLightVariant ? '#333' : '#fff',
|
textColor: colorMode === 'light' ? '#333' : '#fff',
|
||||||
},
|
},
|
||||||
translations: {
|
translations: {
|
||||||
menuTitle: t('journal.pl.serviceMenu.title'),
|
menuTitle: t('journal.pl.serviceMenu.title'),
|
||||||
@@ -102,7 +105,7 @@ export const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очистка при размонтировании
|
// Очистка при размонтировании
|
||||||
return () => {
|
return () => {
|
||||||
if (serviceMenuInstanceRef.current) {
|
if (serviceMenuInstanceRef.current) {
|
||||||
@@ -110,9 +113,10 @@ export const Dashboard = () => {
|
|||||||
serviceMenuInstanceRef.current = null;
|
serviceMenuInstanceRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [keycloak.token, serviceMenu, isLightVariant, isDarkVariant, t]);
|
}, [keycloak.token, serviceMenu, colorMode, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
<BreadcrumbsProvider>
|
<BreadcrumbsProvider>
|
||||||
<HeaderWithBreadcrumbs serviceMenuContainerRef={serviceMenuContainerRef} />
|
<HeaderWithBreadcrumbs serviceMenuContainerRef={serviceMenuContainerRef} />
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -158,5 +162,6 @@ export const Dashboard = () => {
|
|||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BreadcrumbsProvider>
|
</BreadcrumbsProvider>
|
||||||
|
</Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ html {
|
|||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Светлая тема (light) - по умолчанию */
|
|
||||||
body {
|
body {
|
||||||
color: #000;
|
color: #000;
|
||||||
/* background: radial-gradient(circle at top right, rgb(154 227 33), rgb(33 160 56)); */
|
/* background: radial-gradient(circle at top right, rgb(154 227 33), rgb(33 160 56)); */
|
||||||
@@ -33,7 +31,7 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Темная тема (dark) */
|
/* Стили для темной темы */
|
||||||
html[data-theme="dark"] body {
|
html[data-theme="dark"] body {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
@@ -53,86 +51,6 @@ 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 {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { getConfigValue } from '@brojs/cli'
|
|
||||||
import { api } from '../__data__/api/api'
|
|
||||||
import { isTeacher } from '../utils/user'
|
|
||||||
import { User } from '../__data__/model'
|
|
||||||
|
|
||||||
// Using any for user type to avoid type errors with the store user object
|
|
||||||
export const useAccessCode = (lessonId: string, user: any) => {
|
|
||||||
const {
|
|
||||||
isFetching,
|
|
||||||
data: accessCode,
|
|
||||||
isSuccess,
|
|
||||||
refetch,
|
|
||||||
} = api.useCreateAccessCodeQuery(
|
|
||||||
{ lessonId },
|
|
||||||
{
|
|
||||||
skip: !isTeacher(user),
|
|
||||||
pollingInterval:
|
|
||||||
Number(getConfigValue('journal.polling-interval')) || 3000,
|
|
||||||
skipPollingIfUnfocused: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const [manualAdd, manualAddRqst] = api.useManualAddStudentMutation()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (manualAddRqst.isSuccess) {
|
|
||||||
refetch()
|
|
||||||
}
|
|
||||||
}, [manualAddRqst.isSuccess, refetch])
|
|
||||||
|
|
||||||
const handleManualAdd = (user: User) => manualAdd({ lessonId, user })
|
|
||||||
|
|
||||||
return {
|
|
||||||
isFetching,
|
|
||||||
accessCode,
|
|
||||||
isSuccess,
|
|
||||||
refetch,
|
|
||||||
manualAddRqst,
|
|
||||||
handleManualAdd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useEffect, RefObject } from 'react'
|
|
||||||
import QRCode from 'qrcode'
|
|
||||||
|
|
||||||
export const useQRCode = (
|
|
||||||
canvasRef: RefObject<HTMLCanvasElement>,
|
|
||||||
url: string,
|
|
||||||
isFetching: boolean,
|
|
||||||
isSuccess: boolean
|
|
||||||
) => {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFetching && isSuccess) {
|
|
||||||
const generateQRCode = () => {
|
|
||||||
if (!canvasRef.current) return;
|
|
||||||
|
|
||||||
// Получаем текущую ширину канваса, гарантируя квадратный QR-код
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const containerWidth = canvas.clientWidth;
|
|
||||||
|
|
||||||
// Очищаем canvas перед новой генерацией
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// Устанавливаем одинаковые размеры для ширины и высоты (1:1)
|
|
||||||
canvas.width = containerWidth;
|
|
||||||
canvas.height = containerWidth;
|
|
||||||
|
|
||||||
QRCode.toCanvas(
|
|
||||||
canvas,
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
width: containerWidth,
|
|
||||||
margin: 1 // Небольшой отступ для лучшей читаемости
|
|
||||||
},
|
|
||||||
function (error) {
|
|
||||||
if (error) console.error(error)
|
|
||||||
console.log('success!')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем QR-код
|
|
||||||
generateQRCode();
|
|
||||||
|
|
||||||
// Перегенерируем при изменении размера окна
|
|
||||||
const handleResize = () => {
|
|
||||||
generateQRCode();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isFetching, isSuccess, url, canvasRef])
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
||||||
|
|
||||||
import { AccessCode, BaseResponse, User } from '../__data__/model'
|
|
||||||
|
|
||||||
export const useStudentAttendance = (
|
|
||||||
accessCode: BaseResponse<AccessCode>,
|
|
||||||
allStudents: any
|
|
||||||
) => {
|
|
||||||
// Создаем ref для отслеживания ранее присутствовавших студентов
|
|
||||||
const prevPresentStudentsRef = useRef(new Set<string>())
|
|
||||||
|
|
||||||
// Добавляем состояние для отслеживания пульсации
|
|
||||||
const [isPulsing, setIsPulsing] = useState(false)
|
|
||||||
// Отслеживаем предыдущее количество студентов
|
|
||||||
const prevStudentCountRef = useRef(0)
|
|
||||||
|
|
||||||
// Эффект для обнаружения и обновления новых присутствующих студентов
|
|
||||||
useEffect(() => {
|
|
||||||
if (accessCode?.body) {
|
|
||||||
const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub))
|
|
||||||
|
|
||||||
// Проверяем, изменилось ли количество студентов
|
|
||||||
const currentCount = accessCode.body.lesson.students.length;
|
|
||||||
if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) {
|
|
||||||
// Запускаем эффект пульсации
|
|
||||||
setIsPulsing(true);
|
|
||||||
// Сбрасываем эффект через 1.5 секунды
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsPulsing(false);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем предыдущее количество
|
|
||||||
prevStudentCountRef.current = currentCount;
|
|
||||||
|
|
||||||
// Очищаем флаги предыдущего состояния после задержки
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
prevPresentStudentsRef.current = currentPresent
|
|
||||||
}, 3000)
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
}, [accessCode])
|
|
||||||
|
|
||||||
const studentsArr = useMemo(() => {
|
|
||||||
let allStudentsList: (User & { present?: boolean; recentlyPresent?: boolean })[] = [
|
|
||||||
...(allStudents.data?.body || []),
|
|
||||||
].map((st) => ({ ...st, present: false, recentlyPresent: false }))
|
|
||||||
let presentStudents: (User & { present?: boolean })[] = [
|
|
||||||
...(accessCode?.body?.lesson?.students || []),
|
|
||||||
]
|
|
||||||
|
|
||||||
// Находим новых студентов по сравнению с предыдущим состоянием
|
|
||||||
const currentPresent = new Set(presentStudents.map(s => s.sub))
|
|
||||||
const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id))
|
|
||||||
|
|
||||||
while (presentStudents.length) {
|
|
||||||
const student = presentStudents.pop()
|
|
||||||
|
|
||||||
const present = allStudentsList.find((st) => st.sub === student.sub)
|
|
||||||
|
|
||||||
if (present) {
|
|
||||||
present.present = true
|
|
||||||
present.recentlyPresent = newlyPresent.includes(student.sub)
|
|
||||||
} else {
|
|
||||||
allStudentsList.push({
|
|
||||||
...student,
|
|
||||||
present: true,
|
|
||||||
recentlyPresent: newlyPresent.includes(student.sub)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allStudentsList
|
|
||||||
}, [accessCode?.body, allStudents.data, prevPresentStudentsRef.current])
|
|
||||||
|
|
||||||
return {
|
|
||||||
studentsArr,
|
|
||||||
isPulsing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import { Reaction } from '../__data__/model'
|
|
||||||
|
|
||||||
export const useStudentReactions = (accessCode: any) => {
|
|
||||||
// Отслеживаем предыдущие реакции для определения новых
|
|
||||||
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
|
|
||||||
// Храним актуальные реакции студентов
|
|
||||||
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
|
|
||||||
|
|
||||||
// Эффект для обработки новых реакций
|
|
||||||
useEffect(() => {
|
|
||||||
if (accessCode?.body?.lesson?.studentReactions) {
|
|
||||||
const reactions = accessCode.body.lesson.studentReactions;
|
|
||||||
|
|
||||||
// Группируем реакции по sub (идентификатору студента)
|
|
||||||
const groupedReactions: Record<string, Reaction[]> = {};
|
|
||||||
|
|
||||||
reactions.forEach(reaction => {
|
|
||||||
if (!groupedReactions[reaction.sub]) {
|
|
||||||
groupedReactions[reaction.sub] = [];
|
|
||||||
}
|
|
||||||
groupedReactions[reaction.sub].push(reaction);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обновляем отображаемые реакции
|
|
||||||
setStudentReactions(groupedReactions);
|
|
||||||
|
|
||||||
// Обновляем предыдущие реакции после небольшой задержки
|
|
||||||
const updatePrevReactionsTimeout = setTimeout(() => {
|
|
||||||
prevReactionsRef.current = groupedReactions;
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearTimeout(updatePrevReactionsTimeout);
|
|
||||||
}
|
|
||||||
}, [accessCode?.body?.lesson?.studentReactions]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
studentReactions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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,7 +1,10 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useEffect, useRef, useMemo, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
import { sha256 } from 'js-sha256'
|
import { sha256 } from 'js-sha256'
|
||||||
import { getNavigationValue } from '@brojs/cli'
|
import { getConfigValue, getNavigationValue } from '@brojs/cli'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { AddIcon } from '@chakra-ui/icons'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
@@ -9,24 +12,24 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
Stack,
|
Stack,
|
||||||
useColorMode,
|
useColorMode,
|
||||||
|
Flex,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { api } from '../__data__/api/api'
|
import { api } from '../__data__/api/api'
|
||||||
|
import { User, Reaction } from '../__data__/model'
|
||||||
|
import { UserCard } from '../components/user-card'
|
||||||
|
import { formatDate } from '../utils/dayjs-config'
|
||||||
import { useSetBreadcrumbs } from '../components'
|
import { useSetBreadcrumbs } from '../components'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AddMissedButton,
|
||||||
|
QRCanvas,
|
||||||
|
StudentList,
|
||||||
|
} from './style'
|
||||||
import { useAppSelector } from '../__data__/store'
|
import { useAppSelector } from '../__data__/store'
|
||||||
import { isTeacher } from '../utils/user'
|
import { isTeacher } from '../utils/user'
|
||||||
|
|
||||||
// Custom hooks
|
|
||||||
import { useAccessCode } from '../hooks/useAccessCode'
|
|
||||||
import { useStudentAttendance } from '../hooks/useStudentAttendance'
|
|
||||||
import { useStudentReactions } from '../hooks/useStudentReactions'
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import { QRCodeDisplay } from '../components/lesson/QRCodeDisplay'
|
|
||||||
import { AttendanceStats } from '../components/lesson/AttendanceStats'
|
|
||||||
import { StudentList } from '../components/lesson/StudentList'
|
|
||||||
|
|
||||||
export function getGravatarURL(email, user) {
|
export function getGravatarURL(email, user) {
|
||||||
if (!email) return void 0
|
if (!email) return void 0
|
||||||
const address = String(email).trim().toLowerCase()
|
const address = String(email).trim().toLowerCase()
|
||||||
@@ -38,14 +41,15 @@ export function getGravatarURL(email, user) {
|
|||||||
|
|
||||||
const LessonDetail = () => {
|
const LessonDetail = () => {
|
||||||
const { lessonId, courseId } = useParams()
|
const { lessonId, courseId } = useParams()
|
||||||
|
const canvRef = useRef(null)
|
||||||
const user = useAppSelector((s) => s.user)
|
const user = useAppSelector((s) => s.user)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { colorMode } = useColorMode()
|
const { colorMode } = useColorMode()
|
||||||
|
|
||||||
// Получаем данные о курсе и уроке
|
// Получаем данные о курсе и уроке
|
||||||
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
|
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
|
||||||
const { data: lessonData } = api.useLessonByIdQuery(lessonId)
|
const { data: lessonData } = api.useLessonByIdQuery(lessonId)
|
||||||
|
|
||||||
// Устанавливаем хлебные крошки
|
// Устанавливаем хлебные крошки
|
||||||
useSetBreadcrumbs([
|
useSetBreadcrumbs([
|
||||||
{
|
{
|
||||||
@@ -61,26 +65,192 @@ const LessonDetail = () => {
|
|||||||
isCurrentPage: true
|
isCurrentPage: true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// Используем кастомные хуки
|
// Создаем ref для отслеживания ранее присутствовавших студентов
|
||||||
|
const prevPresentStudentsRef = useRef(new Set<string>())
|
||||||
|
|
||||||
|
// Добавляем состояние для отслеживания пульсации
|
||||||
|
const [isPulsing, setIsPulsing] = useState(false)
|
||||||
|
// Отслеживаем предыдущее количество студентов
|
||||||
|
const prevStudentCountRef = useRef(0)
|
||||||
|
// Отслеживаем предыдущие реакции для определения новых
|
||||||
|
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
|
||||||
|
// Храним актуальные реакции студентов
|
||||||
|
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isFetching,
|
isFetching,
|
||||||
accessCode,
|
data: accessCode,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
handleManualAdd
|
refetch,
|
||||||
} = useAccessCode(lessonId, user)
|
} = api.useCreateAccessCodeQuery(
|
||||||
|
{ lessonId },
|
||||||
|
{
|
||||||
|
skip: !isTeacher(user),
|
||||||
|
pollingInterval:
|
||||||
|
Number(getConfigValue('journal.polling-interval')) || 3000,
|
||||||
|
skipPollingIfUnfocused: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
const AllStudents = api.useCourseAllStudentsQuery(courseId)
|
const AllStudents = api.useCourseAllStudentsQuery(courseId)
|
||||||
|
const [manualAdd, manualAddRqst] = api.useManualAddStudentMutation()
|
||||||
const { studentsArr, isPulsing } = useStudentAttendance(accessCode, AllStudents)
|
|
||||||
const { studentReactions } = useStudentReactions(accessCode)
|
|
||||||
|
|
||||||
// Создаем URL для QR-кода
|
|
||||||
const userUrl = useMemo(
|
const userUrl = useMemo(
|
||||||
() => `${location.origin}/journal/u/${lessonId}/${accessCode?.body?._id}`,
|
() => `${location.origin}/journal/u/${lessonId}/${accessCode?.body?._id}`,
|
||||||
[accessCode, lessonId],
|
[accessCode, lessonId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Эффект для обнаружения и обновления новых присутствующих студентов
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessCode?.body) {
|
||||||
|
const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub))
|
||||||
|
|
||||||
|
// Проверяем, изменилось ли количество студентов
|
||||||
|
const currentCount = accessCode.body.lesson.students.length;
|
||||||
|
if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) {
|
||||||
|
// Запускаем эффект пульсации
|
||||||
|
setIsPulsing(true);
|
||||||
|
// Сбрасываем эффект через 1.5 секунды
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsPulsing(false);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем предыдущее количество
|
||||||
|
prevStudentCountRef.current = currentCount;
|
||||||
|
|
||||||
|
// Очищаем флаги предыдущего состояния после задержки
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
prevPresentStudentsRef.current = currentPresent
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [accessCode])
|
||||||
|
|
||||||
|
// Эффект для обработки новых реакций
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessCode?.body?.lesson?.studentReactions) {
|
||||||
|
const reactions = accessCode.body.lesson.studentReactions;
|
||||||
|
|
||||||
|
// Группируем реакции по sub (идентификатору студента)
|
||||||
|
const groupedReactions: Record<string, Reaction[]> = {};
|
||||||
|
|
||||||
|
reactions.forEach(reaction => {
|
||||||
|
if (!groupedReactions[reaction.sub]) {
|
||||||
|
groupedReactions[reaction.sub] = [];
|
||||||
|
}
|
||||||
|
groupedReactions[reaction.sub].push(reaction);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем отображаемые реакции
|
||||||
|
setStudentReactions(groupedReactions);
|
||||||
|
|
||||||
|
// Обновляем предыдущие реакции после небольшой задержки
|
||||||
|
const updatePrevReactionsTimeout = setTimeout(() => {
|
||||||
|
prevReactionsRef.current = groupedReactions;
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(updatePrevReactionsTimeout);
|
||||||
|
}
|
||||||
|
}, [accessCode?.body?.lesson?.studentReactions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (manualAddRqst.isSuccess) {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
}, [manualAddRqst.isSuccess])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFetching && isSuccess) {
|
||||||
|
const generateQRCode = () => {
|
||||||
|
if (!canvRef.current) return;
|
||||||
|
|
||||||
|
// Получаем текущую ширину канваса, гарантируя квадратный QR-код
|
||||||
|
const canvas = canvRef.current;
|
||||||
|
const containerWidth = canvas.clientWidth;
|
||||||
|
|
||||||
|
// Очищаем canvas перед новой генерацией
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Устанавливаем одинаковые размеры для ширины и высоты (1:1)
|
||||||
|
canvas.width = containerWidth;
|
||||||
|
canvas.height = containerWidth;
|
||||||
|
|
||||||
|
QRCode.toCanvas(
|
||||||
|
canvas,
|
||||||
|
userUrl,
|
||||||
|
{
|
||||||
|
width: containerWidth,
|
||||||
|
margin: 1 // Небольшой отступ для лучшей читаемости
|
||||||
|
},
|
||||||
|
function (error) {
|
||||||
|
if (error) console.error(error)
|
||||||
|
console.log('success!')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем QR-код
|
||||||
|
generateQRCode();
|
||||||
|
|
||||||
|
// Перегенерируем при изменении размера окна
|
||||||
|
const handleResize = () => {
|
||||||
|
generateQRCode();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isFetching, isSuccess, userUrl])
|
||||||
|
|
||||||
|
const studentsArr = useMemo(() => {
|
||||||
|
let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [
|
||||||
|
...(AllStudents.data?.body || []),
|
||||||
|
].map((st) => ({ ...st, present: false, recentlyPresent: false }))
|
||||||
|
let presentStudents: (User & { present?: boolean })[] = [
|
||||||
|
...(accessCode?.body.lesson.students || []),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Находим новых студентов по сравнению с предыдущим состоянием
|
||||||
|
const currentPresent = new Set(presentStudents.map(s => s.sub))
|
||||||
|
const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id))
|
||||||
|
|
||||||
|
while (presentStudents.length) {
|
||||||
|
const student = presentStudents.pop()
|
||||||
|
|
||||||
|
const present = allStudents.find((st) => st.sub === student.sub)
|
||||||
|
|
||||||
|
if (present) {
|
||||||
|
present.present = true
|
||||||
|
present.recentlyPresent = newlyPresent.includes(student.sub)
|
||||||
|
} else {
|
||||||
|
allStudents.push({
|
||||||
|
...student,
|
||||||
|
present: true,
|
||||||
|
recentlyPresent: newlyPresent.includes(student.sub)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing the sorting to prevent reordering animation
|
||||||
|
return allStudents
|
||||||
|
}, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current])
|
||||||
|
|
||||||
|
// Функция для определения цвета на основе посещаемости
|
||||||
|
const getAttendanceColor = (attendance: number, total: number) => {
|
||||||
|
const percentage = total > 0 ? (attendance / total) * 100 : 0
|
||||||
|
|
||||||
|
if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } }
|
||||||
|
if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } }
|
||||||
|
if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } }
|
||||||
|
if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
|
||||||
|
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container maxW="2280px">
|
<Container maxW="2280px">
|
||||||
@@ -89,46 +259,250 @@ const LessonDetail = () => {
|
|||||||
{t('journal.pl.lesson.topicTitle')}
|
{t('journal.pl.lesson.topicTitle')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Box as="span">{accessCode?.body?.lesson?.name}</Box>
|
<Box as="span">{accessCode?.body?.lesson?.name}</Box>
|
||||||
|
|
||||||
</VStack>
|
</VStack>
|
||||||
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
|
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
|
||||||
<Box
|
<Box
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
alignSelf="flex-start"
|
alignSelf="flex-start"
|
||||||
p={4}
|
p={4}
|
||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
position="sticky"
|
position="sticky"
|
||||||
top="20px"
|
top="20px"
|
||||||
zIndex="2"
|
zIndex="2"
|
||||||
>
|
><Box pb={3}>
|
||||||
<AttendanceStats
|
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
|
||||||
lessonDate={accessCode?.body?.lesson?.date}
|
{t('journal.pl.common.marked')} -
|
||||||
studentsPresent={accessCode?.body?.lesson?.students?.length || 0}
|
{AllStudents.isSuccess && (
|
||||||
totalStudents={AllStudents?.data?.body?.length || 0}
|
<Box
|
||||||
isLoading={!AllStudents.isSuccess}
|
as="span"
|
||||||
isPulsing={isPulsing}
|
px={2}
|
||||||
/>
|
py={1}
|
||||||
<QRCodeDisplay
|
ml={2}
|
||||||
url={userUrl}
|
borderRadius="md"
|
||||||
isFetching={isFetching}
|
fontWeight="bold"
|
||||||
isSuccess={isSuccess}
|
bg={getAttendanceColor(
|
||||||
/>
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
|
AllStudents?.data?.body?.length || 1
|
||||||
|
).bg}
|
||||||
|
color={getAttendanceColor(
|
||||||
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
|
AllStudents?.data?.body?.length || 1
|
||||||
|
).color}
|
||||||
|
_dark={{
|
||||||
|
bg: getAttendanceColor(
|
||||||
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
|
AllStudents?.data?.body?.length || 1
|
||||||
|
).dark.bg,
|
||||||
|
color: getAttendanceColor(
|
||||||
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
|
AllStudents?.data?.body?.length || 1
|
||||||
|
).dark.color
|
||||||
|
}}
|
||||||
|
position="relative"
|
||||||
|
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
|
||||||
|
sx={{
|
||||||
|
'@keyframes pulse': {
|
||||||
|
'0%': { transform: 'scale(1)' },
|
||||||
|
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
|
||||||
|
'100%': { transform: 'scale(1)' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!AllStudents.isSuccess && (
|
||||||
|
<span> {accessCode?.body?.lesson?.students?.length}</span>
|
||||||
|
)}{' '}
|
||||||
|
{t('journal.pl.common.people')}
|
||||||
|
</Box>
|
||||||
|
<a href={userUrl}>
|
||||||
|
<QRCanvas ref={canvRef} />
|
||||||
|
</a>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
flex={1}
|
flex={1}
|
||||||
p={4}
|
p={4}
|
||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
>
|
>
|
||||||
{isTeacher(user) && (
|
<StudentList>
|
||||||
<StudentList
|
{isTeacher(user) && (
|
||||||
students={studentsArr}
|
<AnimatePresence initial={false}>
|
||||||
onAddUser={handleManualAdd}
|
{studentsArr.map((student) => (
|
||||||
studentReactions={studentReactions}
|
<motion.li
|
||||||
/>
|
key={student.sub}
|
||||||
)}
|
animate={{
|
||||||
|
rotateY: student.present ? 0 : 180,
|
||||||
|
boxShadow: student.recentlyPresent
|
||||||
|
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
|
||||||
|
: '0 0 0 0 rgba(0, 0, 0, 0)'
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
rotateY: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
|
boxShadow: {
|
||||||
|
repeat: student.recentlyPresent ? 3 : 0,
|
||||||
|
duration: 1.5
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
transformStyle: "preserve-3d",
|
||||||
|
perspective: "1000px",
|
||||||
|
aspectRatio: "1",
|
||||||
|
width: "100%",
|
||||||
|
display: "block"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Front side - visible when present */}
|
||||||
|
<Box
|
||||||
|
position="relative"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{
|
||||||
|
transformStyle: "preserve-3d"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
transform: "rotateY(0deg)",
|
||||||
|
zIndex: student.present ? 1 : 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserCard
|
||||||
|
wrapperAS="div"
|
||||||
|
student={student}
|
||||||
|
present={student.present}
|
||||||
|
recentlyPresent={student.recentlyPresent}
|
||||||
|
reaction={accessCode?.body?.lesson?.studentReactions?.find(r => r.sub === student.sub)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Back side - visible when not present */}
|
||||||
|
<Flex
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
bg={colorMode === "light" ? "gray.100" : "gray.600"}
|
||||||
|
borderRadius="12px"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
p={4}
|
||||||
|
overflow="hidden"
|
||||||
|
style={{
|
||||||
|
backfaceVisibility: "hidden",
|
||||||
|
transform: "rotateY(180deg)",
|
||||||
|
zIndex: student.present ? 0 : 1,
|
||||||
|
aspectRatio: "1"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddMissedButton
|
||||||
|
onClick={() => manualAdd({ lessonId, user: student })}
|
||||||
|
aria-label={t('journal.pl.common.add')}
|
||||||
|
>
|
||||||
|
<AddIcon boxSize={3} />
|
||||||
|
</AddMissedButton>
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
right="0"
|
||||||
|
bottom="0"
|
||||||
|
opacity="0.2"
|
||||||
|
className="animated-bg"
|
||||||
|
sx={{
|
||||||
|
background: `linear-gradient(135deg,
|
||||||
|
${colorMode === "light" ? "#e3f2fd, #bbdefb, #90caf9" : "#1a365d, #2a4365, #2c5282"})`,
|
||||||
|
backgroundSize: "400% 400%",
|
||||||
|
animation: "gradientAnimation 8s ease infinite",
|
||||||
|
"@keyframes gradientAnimation": {
|
||||||
|
"0%": { backgroundPosition: "0% 50%" },
|
||||||
|
"50%": { backgroundPosition: "100% 50%" },
|
||||||
|
"100%": { backgroundPosition: "0% 50%" }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
position="relative"
|
||||||
|
textAlign="center"
|
||||||
|
zIndex="1"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
width="60px"
|
||||||
|
height="60px"
|
||||||
|
mx="auto"
|
||||||
|
mb={2}
|
||||||
|
sx={{
|
||||||
|
animation: "float 3s ease-in-out infinite",
|
||||||
|
"@keyframes float": {
|
||||||
|
"0%": { transform: "translateY(0px)" },
|
||||||
|
"50%": { transform: "translateY(-10px)" },
|
||||||
|
"100%": { transform: "translateY(0px)" }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* Академическая шапочка */}
|
||||||
|
<path
|
||||||
|
d="M12 2L2 6.5L12 11L22 6.5L12 2Z"
|
||||||
|
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 9V14.5C19 15.163 18.6839 15.7989 18.1213 16.2678C17.0615 17.1301 13.7749 19 12 19C10.2251 19 6.93852 17.1301 5.87868 16.2678C5.31607 15.7989 5 15.163 5 14.5V9L12 12.5L19 9Z"
|
||||||
|
fill={colorMode === "light" ? "#2C5282" : "#4299E1"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 7V14M21 14L19 16M21 14L23 16"
|
||||||
|
stroke={colorMode === "light" ? "#2C5282" : "#4299E1"}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Лицо студента */}
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="15"
|
||||||
|
r="2.5"
|
||||||
|
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Тело студента */}
|
||||||
|
<path
|
||||||
|
d="M8 18.5C8 17.1193 9.11929 16 10.5 16H13.5C14.8807 16 16 17.1193 16 18.5V21H8V18.5Z"
|
||||||
|
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
<Box fontSize="sm" fontWeight="medium">
|
||||||
|
{student.name || student.preferred_username}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
fontSize="xs"
|
||||||
|
opacity={0.8}
|
||||||
|
color={colorMode === "light" ? "gray.600" : "gray.300"}
|
||||||
|
>
|
||||||
|
{t('journal.pl.lesson.notMarked')}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</StudentList>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { getFeatures } from '@brojs/cli'
|
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -30,11 +29,7 @@ import {
|
|||||||
Wrap,
|
Wrap,
|
||||||
WrapItem,
|
WrapItem,
|
||||||
IconButton,
|
IconButton,
|
||||||
Center,
|
Center
|
||||||
InputGroup,
|
|
||||||
InputRightElement,
|
|
||||||
List,
|
|
||||||
ListItem
|
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { AddIcon, CheckIcon, WarningIcon, RepeatIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'
|
import { AddIcon, CheckIcon, WarningIcon, RepeatIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -45,10 +40,6 @@ import { formatDate } from '../../../utils/dayjs-config'
|
|||||||
import { dateToCalendarFormat } from '../../../utils/time'
|
import { dateToCalendarFormat } from '../../../utils/time'
|
||||||
import { Lesson } from '../../../__data__/model'
|
import { Lesson } from '../../../__data__/model'
|
||||||
import { ErrorSpan } from '../style'
|
import { ErrorSpan } from '../style'
|
||||||
import { api } from '../../../__data__/api/api'
|
|
||||||
import { useParams } from 'react-router-dom'
|
|
||||||
|
|
||||||
const courceNameSuggestion = getFeatures('journal')['courceNameSuggestion']
|
|
||||||
|
|
||||||
interface NewLessonForm {
|
interface NewLessonForm {
|
||||||
name: string
|
name: string
|
||||||
@@ -87,7 +78,6 @@ export const LessonForm = ({
|
|||||||
onRetryAiGeneration = () => {},
|
onRetryAiGeneration = () => {},
|
||||||
existingLessons
|
existingLessons
|
||||||
}: LessonFormProps) => {
|
}: LessonFormProps) => {
|
||||||
const { courseId } = useParams()
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isAiSuggested = lesson && !lesson._id && !lesson.id
|
const isAiSuggested = lesson && !lesson._id && !lesson.id
|
||||||
const aiHighlightColor = useColorModeValue('blue.100', 'blue.800')
|
const aiHighlightColor = useColorModeValue('blue.100', 'blue.800')
|
||||||
@@ -95,72 +85,6 @@ export const LessonForm = ({
|
|||||||
const suggestionHoverBgColor = useColorModeValue('blue.100', 'blue.800')
|
const suggestionHoverBgColor = useColorModeValue('blue.100', 'blue.800')
|
||||||
const borderColor = useColorModeValue('blue.200', 'blue.700')
|
const borderColor = useColorModeValue('blue.200', 'blue.700')
|
||||||
const textSecondaryColor = useColorModeValue('gray.600', 'gray.400')
|
const textSecondaryColor = useColorModeValue('gray.600', 'gray.400')
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
||||||
const [inputValue, setInputValue] = useState('')
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const suggestionsContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
|
|
||||||
const [generateLessonName, {
|
|
||||||
data: generateLessonNameData,
|
|
||||||
isLoading: isLoadingGenerateLessonName,
|
|
||||||
error: errorGenerateLessonName,
|
|
||||||
isSuccess: isSuccessGenerateLessonName
|
|
||||||
}] = api.useGenerateLessonNameMutation()
|
|
||||||
|
|
||||||
// Функция debounce для запросов
|
|
||||||
const debouncedGenerateName = useCallback((value: string) => {
|
|
||||||
if (debounceTimeoutRef.current) {
|
|
||||||
clearTimeout(debounceTimeoutRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
debounceTimeoutRef.current = setTimeout(() => {
|
|
||||||
if (value.length > 2) {
|
|
||||||
generateLessonName({ courseId: courseId, name: value })
|
|
||||||
} else {
|
|
||||||
setSuggestions([])
|
|
||||||
}
|
|
||||||
}, Number(courceNameSuggestion.value) || 300)
|
|
||||||
}, [courseId, generateLessonName])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSuccessGenerateLessonName) {
|
|
||||||
setSuggestions(generateLessonNameData.body.map(suggestion => suggestion.name))
|
|
||||||
}
|
|
||||||
}, [isSuccessGenerateLessonName])
|
|
||||||
|
|
||||||
// Эффект для корректного позиционирования списка подсказок
|
|
||||||
useEffect(() => {
|
|
||||||
const positionSuggestions = () => {
|
|
||||||
if (inputRef.current && suggestionsContainerRef.current && showSuggestions) {
|
|
||||||
const inputRect = inputRef.current.getBoundingClientRect()
|
|
||||||
suggestionsContainerRef.current.style.top = `${inputRect.bottom + window.scrollY}px`
|
|
||||||
suggestionsContainerRef.current.style.left = `${inputRect.left + window.scrollX}px`
|
|
||||||
suggestionsContainerRef.current.style.width = `${inputRect.width}px`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
positionSuggestions()
|
|
||||||
|
|
||||||
// Обновляем позицию при скролле или изменении размера окна
|
|
||||||
window.addEventListener('scroll', positionSuggestions)
|
|
||||||
window.addEventListener('resize', positionSuggestions)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', positionSuggestions)
|
|
||||||
window.removeEventListener('resize', positionSuggestions)
|
|
||||||
}
|
|
||||||
}, [showSuggestions, suggestions.length])
|
|
||||||
|
|
||||||
// Эффект для очистки timeout при размонтировании компонента
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (debounceTimeoutRef.current) {
|
|
||||||
clearTimeout(debounceTimeoutRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getNearestTimeSlot = () => {
|
const getNearestTimeSlot = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -225,6 +149,27 @@ export const LessonForm = ({
|
|||||||
return slots;
|
return slots;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNextTimeSlots = (date: string, count: number = 3) => {
|
||||||
|
const currentDate = new Date();
|
||||||
|
const selectedDate = new Date(date);
|
||||||
|
const isToday = selectedDate.toDateString() === currentDate.toDateString();
|
||||||
|
|
||||||
|
if (!isToday) return [];
|
||||||
|
|
||||||
|
const currentMinutes = currentDate.getHours() * 60 + currentDate.getMinutes();
|
||||||
|
const slots = generateTimeSlots();
|
||||||
|
|
||||||
|
return slots
|
||||||
|
.map(slot => {
|
||||||
|
const [hours, minutes] = slot.split(':').map(Number);
|
||||||
|
const slotMinutes = hours * 60 + minutes;
|
||||||
|
return { slot, minutes: slotMinutes };
|
||||||
|
})
|
||||||
|
.filter(({ minutes }) => minutes > currentMinutes)
|
||||||
|
.slice(0, count)
|
||||||
|
.map(({ slot }) => slot);
|
||||||
|
};
|
||||||
|
|
||||||
const timeGroups = {
|
const timeGroups = {
|
||||||
[`${t('journal.pl.days.morning')} (8-12)`]: generateTimeSlots().filter(slot => {
|
[`${t('journal.pl.days.morning')} (8-12)`]: generateTimeSlots().filter(slot => {
|
||||||
const hour = parseInt(slot.split(':')[0]);
|
const hour = parseInt(slot.split(':')[0]);
|
||||||
@@ -545,30 +490,12 @@ export const LessonForm = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormControl isRequired isInvalid={Boolean(errors.name)}>
|
<FormControl isRequired isInvalid={Boolean(errors.name)}>
|
||||||
<FormLabel>{t('journal.pl.lesson.form.title')}</FormLabel>
|
<FormLabel>{t('journal.pl.lesson.form.title')}</FormLabel>
|
||||||
<Box position="relative">
|
<Input
|
||||||
<InputGroup>
|
{...field}
|
||||||
<Input
|
required={false}
|
||||||
{...field}
|
placeholder={t('journal.pl.lesson.form.namePlaceholder')}
|
||||||
ref={inputRef}
|
size="md"
|
||||||
required={false}
|
/>
|
||||||
placeholder={t('journal.pl.lesson.form.namePlaceholder')}
|
|
||||||
size="md"
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value
|
|
||||||
setInputValue(value)
|
|
||||||
field.onChange(value)
|
|
||||||
if (value.length > 2 && courceNameSuggestion) {
|
|
||||||
setShowSuggestions(true)
|
|
||||||
debouncedGenerateName(value)
|
|
||||||
} else {
|
|
||||||
setSuggestions([])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => setShowSuggestions(true)}
|
|
||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</Box>
|
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<FormErrorMessage>{errors.name.message}</FormErrorMessage>
|
<FormErrorMessage>{errors.name.message}</FormErrorMessage>
|
||||||
)}
|
)}
|
||||||
@@ -683,39 +610,6 @@ export const LessonForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
||||||
{/* Выпадающий список подсказок (размещаем вне стандартного потока документа) */}
|
|
||||||
{suggestions.length > 0 && showSuggestions && (
|
|
||||||
<Box
|
|
||||||
position="fixed"
|
|
||||||
ref={suggestionsContainerRef}
|
|
||||||
bg={useColorModeValue('white', 'gray.800')}
|
|
||||||
borderRadius="md"
|
|
||||||
boxShadow="md"
|
|
||||||
zIndex={9999}
|
|
||||||
maxH="200px"
|
|
||||||
overflowY="auto"
|
|
||||||
>
|
|
||||||
<List>
|
|
||||||
{suggestions.map((suggestion, index) => (
|
|
||||||
<ListItem
|
|
||||||
key={index}
|
|
||||||
p={2}
|
|
||||||
cursor="pointer"
|
|
||||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
|
||||||
onClick={() => {
|
|
||||||
setValue('name', suggestion)
|
|
||||||
setInputValue(suggestion)
|
|
||||||
setSuggestions([])
|
|
||||||
setShowSuggestions(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,34 @@ const reveal = keyframes`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const AddMissedButton = styled.button`
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--chakra-colors-blue-500);
|
||||||
|
color: white;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chakra-ui-dark & {
|
||||||
|
background-color: var(--chakra-colors-blue-400);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const StudentList = styled.ul`
|
export const StudentList = styled.ul`
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type ThemeType = 'light' | 'dark' | 'pink' | 'blue' | 'green' | 'purple';
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Функция для определения цвета на основе посещаемости
|
|
||||||
export const getAttendanceColor = (attendance: number, total: number) => {
|
|
||||||
const percentage = total > 0 ? (attendance / total) * 100 : 0
|
|
||||||
|
|
||||||
if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } }
|
|
||||||
if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } }
|
|
||||||
if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } }
|
|
||||||
if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
|
|
||||||
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -131,23 +131,6 @@ router.get('/lesson/:courseId/ai/generate-lessons', timer(3000), (req, res) => {
|
|||||||
res.send(modifiedData);
|
res.send(modifiedData);
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/lesson/:courseId/ai/generate-lesson-name', timer(3000), (req, res) => {
|
|
||||||
res.send({
|
|
||||||
"success": true,
|
|
||||||
"body": [
|
|
||||||
{
|
|
||||||
"name": "Основы CSS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CSS селекторы и свойства"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Анимации и переходы на CSS"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/lesson', (req, res) => {
|
router.post('/lesson', (req, res) => {
|
||||||
const baseData = readJsonFile('../mocks/lessons/create/success.json');
|
const baseData = readJsonFile('../mocks/lessons/create/success.json');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user