18 Commits
master ... vibe

Author SHA1 Message Date
Primakov Alexandr Alexandrovich
9cbc5910ef vibe themes
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-04-24 17:24:07 +03:00
Primakov Alexandr Alexandrovich
092577f192 3.18.2 2025-04-06 18:11:42 +03:00
Primakov Alexandr Alexandrovich
05f28b4bdf debounce by ffeature value time 2025-04-06 18:10:55 +03:00
Primakov Alexandr Alexandrovich
18b0bdbab7 3.18.1 2025-04-06 18:07:51 +03:00
Primakov Alexandr Alexandrovich
cb60801a74 fix courseId undefined 2025-04-06 18:07:15 +03:00
Primakov Alexandr Alexandrovich
40214cef3f 3.18.0 2025-04-06 17:44:36 +03:00
Primakov Alexandr Alexandrovich
386d2b409d courceNameSuggestion feature 2025-04-06 17:44:26 +03:00
5debb1ebe9 beauty cards 2025-04-04 00:11:53 +03:00
4157cd574b 3.17.4 2025-04-03 23:28:38 +03:00
8fe7850f05 3.17.3 2025-04-03 23:16:33 +03:00
688337f778 fix add student 2025-04-03 23:16:27 +03:00
f6edca8c75 mark student button 2025-04-03 23:12:19 +03:00
80338c9bfc 3.17.2 2025-03-28 21:58:31 +03:00
23e8b81aa7 more chance to see picture on user card 2025-03-28 21:58:16 +03:00
99dff44fd1 3.17.1 2025-03-28 21:52:11 +03:00
ad6b36c4ca shift from gitea to npm registry 2025-03-28 21:52:05 +03:00
8f67b7511d 3.17.0 2025-03-28 21:48:11 +03:00
bda0d1673e bettary power attendance 2025-03-28 21:48:05 +03:00
37 changed files with 2332 additions and 1001 deletions

View File

@@ -44,6 +44,11 @@ module.exports = {
value: '',
key: 'courses.statistics',
},
'courceNameSuggestion': {
on: true,
value: '',
key: 'courceNameSuggestion',
},
},
},
config: {

View File

@@ -93,6 +93,14 @@
"journal.pl.lesson.noStudents": "No Students Yet",
"journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here",
"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.heart": "Heart",
@@ -149,6 +157,13 @@
"journal.pl.theme.switchDark": "Switch to dark theme",
"journal.pl.theme.switchLight": "Switch to light theme",
"journal.pl.theme.select": "Select theme",
"journal.pl.theme.light": "Light",
"journal.pl.theme.dark": "Dark",
"journal.pl.theme.pink": "Pink",
"journal.pl.theme.blue": "Blue",
"journal.pl.theme.green": "Green",
"journal.pl.theme.purple": "Purple",
"journal.pl.lang.switchToEn": "Switch to English",
"journal.pl.lang.switchToRu": "Switch to Russian",

View File

@@ -90,6 +90,14 @@
"journal.pl.lesson.noStudents": "Пока нет студентов",
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
"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.heart": "Сердце",
@@ -146,6 +154,13 @@
"journal.pl.theme.switchDark": "Переключить на темную тему",
"journal.pl.theme.switchLight": "Переключить на светлую тему",
"journal.pl.theme.select": "Выбрать тему",
"journal.pl.theme.light": "Светлая",
"journal.pl.theme.dark": "Тёмная",
"journal.pl.theme.pink": "Розовая",
"journal.pl.theme.blue": "Синяя",
"journal.pl.theme.green": "Зелёная",
"journal.pl.theme.purple": "Фиолетовая",
"journal.pl.lang.switchToEn": "Переключить на английский",
"journal.pl.lang.switchToRu": "Переключить на русский",

909
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "journal.pl",
"version": "3.16.4",
"version": "3.18.2",
"description": "bro-js platform journal ui repo",
"main": "./src/index.tsx",
"scripts": {

View File

@@ -73,6 +73,14 @@ export const api = createApi({
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<
BaseResponse<Lesson>,
Partial<Lesson> & Pick<Lesson, 'name' | 'date'> & { courseId: string }

View File

@@ -0,0 +1,69 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ThemeType } from '../../types/theme';
import {
LIGHT_THEME,
DARK_THEME,
PINK_THEME,
BLUE_THEME,
GREEN_THEME,
PURPLE_THEME,
THEMES,
getNextTheme
} from '../../utils/themes';
// Ключ для хранения текущей темы в localStorage
const THEME_STORAGE_KEY = 'journal-pl-theme';
// Получаем сохраненную тему из localStorage или используем светлую тему по умолчанию
const getSavedTheme = (): ThemeType => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as ThemeType | null;
if (savedTheme && THEMES.includes(savedTheme as ThemeType)) {
return savedTheme as ThemeType;
}
}
// По умолчанию используем светлую тему
return LIGHT_THEME;
};
interface ThemeState {
currentTheme: ThemeType;
}
const initialState: ThemeState = {
currentTheme: getSavedTheme(),
};
export const themeSlice = createSlice({
name: 'theme',
initialState,
reducers: {
setTheme: (state, action: PayloadAction<ThemeType>) => {
state.currentTheme = action.payload;
// Сохраняем выбранную тему в localStorage
if (typeof window !== 'undefined') {
localStorage.setItem(THEME_STORAGE_KEY, action.payload);
}
},
cycleNextTheme: (state) => {
state.currentTheme = getNextTheme(state.currentTheme);
// Сохраняем выбранную тему в localStorage
if (typeof window !== 'undefined') {
localStorage.setItem(THEME_STORAGE_KEY, state.currentTheme);
}
},
},
});
export const { setTheme, cycleNextTheme } = themeSlice.actions;
// Селекторы для получения информации о текущей теме
export const selectCurrentTheme = (state: { theme: ThemeState }) => state.theme.currentTheme;
export const selectIsLightVariant = (state: { theme: ThemeState }) =>
[LIGHT_THEME, PINK_THEME, BLUE_THEME, GREEN_THEME].includes(state.theme.currentTheme);
export const selectIsDarkVariant = (state: { theme: ThemeState }) =>
[DARK_THEME, PURPLE_THEME].includes(state.theme.currentTheme);
export default themeSlice.reducer;

View File

@@ -3,6 +3,7 @@ import { TypedUseSelectorHook, useSelector } from 'react-redux'
import { api } from './api/api'
import { userSlice } from './slices/user'
import { themeSlice } from './slices/theme'
export const createStore = (preloadedState = {}) =>
configureStore({
@@ -10,6 +11,7 @@ export const createStore = (preloadedState = {}) =>
reducer: {
[api.reducerPath]: api.reducer,
[userSlice.name]: userSlice.reducer,
[themeSlice.name]: themeSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({

View File

@@ -4,18 +4,12 @@ import { Global } from '@emotion/react'
import { BrowserRouter } from 'react-router-dom';
import dayjs from './utils/dayjs-config';
import { useTranslation } from 'react-i18next';
import { ChakraProvider, ColorModeScript, extendTheme } from '@chakra-ui/react'
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react'
import { Provider } from 'react-redux'
import { Dashboard } from './dashboard';
import { globalStyles } from './global.styles';
// Расширяем тему Chakra UI
const theme = extendTheme({
config: {
initialColorMode: 'light',
useSystemColorMode: false,
},
})
import { chakraTheme } from './utils/theme';
interface AppProps {
store: any; // Тип для store зависит от конкретной реализации хранилища
@@ -24,17 +18,19 @@ interface AppProps {
const App: React.FC<AppProps> = ({ store }) => {
const { t } = useTranslation();
return (
<ChakraProvider theme={theme}>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<Provider store={store}>
<ChakraProvider theme={chakraTheme}>
<ColorModeScript initialColorMode={chakraTheme.config.initialColorMode} />
<BrowserRouter>
<Helmet>
<meta name="viewport" content="width=device-width, user-scalable=no" />
<title>{t('journal.pl.title')}</title>
</Helmet>
<Global styles={globalStyles} />
<Dashboard store={store} />
<Dashboard />
</BrowserRouter>
</ChakraProvider>
</Provider>
)
}

View File

@@ -21,6 +21,10 @@ import { MoonIcon, SunIcon, ChevronRightIcon, InfoIcon, ChevronDownIcon } from '
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { getNavigationValue } from '@brojs/cli';
import { ThemeSelector } from '../theme-selector';
import { useThemeManager } from '../../hooks/useThemeManager';
import { useAppSelector } from '../../__data__/store';
import { selectCurrentTheme } from '../../__data__/slices/theme';
interface AppHeaderProps {
serviceMenuContainerRef?: React.RefObject<HTMLDivElement>;
@@ -32,7 +36,9 @@ interface AppHeaderProps {
}
export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderProps) => {
const { colorMode, toggleColorMode } = useColorMode();
const { isLightVariant, isDarkVariant } = useThemeManager();
// Используем напрямую селектор из Redux для получения темы и для передачи в key
const currentTheme = useAppSelector(selectCurrentTheme);
const { t, i18n } = useTranslation();
const location = useLocation();
@@ -55,11 +61,11 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
};
// Определяем размеры для разных устройств
const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' });
const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' }, { ssr: false });
// Проверяем, на каком устройстве находимся
const [isLargerThan768] = useMediaQuery("(min-width: 768px)");
const [isLargerThan480] = useMediaQuery("(min-width: 480px)");
const [isLargerThan768] = useMediaQuery("(min-width: 768px)", { ssr: false });
const [isLargerThan480] = useMediaQuery("(min-width: 480px)", { ssr: false });
// Вертикальное отображение на мобильных устройствах
const isMobile = !isLargerThan480;
@@ -68,23 +74,25 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
const horizontalSeparator = useBreakpointValue({
sm: <ChevronRightIcon color="gray.400" fontSize="xs" />,
md: <ChevronRightIcon color="gray.400" />
});
}, { ssr: false });
const toggleLanguage = () => {
const newLang = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(newLang);
};
// Используем key={currentTheme} для принудительного перерендеринга при изменении темы
return (
<Box
as="header"
width="100%"
py={{ base: 2, md: 3 }}
bg={colorMode === 'light' ? 'white' : 'gray.800'}
bg={isLightVariant ? 'white' : 'gray.800'}
boxShadow="sm"
position="sticky"
top={0}
zIndex={10}
key={currentTheme} // Добавляем ключ для принудительного перерендеринга при изменении темы
>
{/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */}
{serviceMenuContainerRef && (
@@ -126,18 +134,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
{i18n.language === 'ru' ? 'EN' : 'RU'}
</Button>
<IconButton
aria-label={colorMode === 'light'
? t('journal.pl.theme.switchDark')
: t('journal.pl.theme.switchLight')
}
icon={colorMode === 'light' ? <MoonIcon boxSize={{ base: "14px" }} /> : <SunIcon boxSize={{ base: "14px" }} />}
onClick={toggleColorMode}
variant="ghost"
size={{ base: "sm" }}
minW={{ base: "30px" }}
h={{ base: "30px" }}
/>
<ThemeSelector variant="icon" size="sm" />
</HStack>
</Flex>
@@ -157,7 +154,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
py={1}
borderRadius="md"
_hover={!crumb.isCurrentPage && crumb.path ? {
bg: colorMode === 'light' ? 'gray.50' : 'gray.700',
bg: isLightVariant ? 'gray.50' : 'gray.700',
} : {}}
>
{index > 0 && (
@@ -184,7 +181,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
) : (
<Text
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
color={crumb.isCurrentPage ? (isLightVariant ? 'cyan.600' : 'cyan.300') : undefined}
fontSize={fontSize}
noOfLines={1}
title={crumb.title}
@@ -226,7 +223,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
textOverflow="ellipsis"
display="inline-block"
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
color={crumb.isCurrentPage ? (isLightVariant ? 'cyan.600' : 'cyan.300') : undefined}
title={crumb.title}
>
{crumb.title}
@@ -254,18 +251,7 @@ export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderPro
{i18n.language === 'ru' ? 'EN' : 'RU'}
</Button>
<IconButton
aria-label={colorMode === 'light'
? t('journal.pl.theme.switchDark')
: t('journal.pl.theme.switchLight')
}
icon={colorMode === 'light' ? <MoonIcon boxSize={{ sm: "14px", md: "16px" }} /> : <SunIcon boxSize={{ sm: "14px", md: "16px" }} />}
onClick={toggleColorMode}
variant="ghost"
size={{ sm: "sm", md: "md" }}
minW={{ sm: "34px" }}
h={{ sm: "34px" }}
/>
<ThemeSelector variant="icon" size="sm" />
</HStack>
</Flex>
)}

View File

@@ -0,0 +1,63 @@
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>
)
}

View File

@@ -0,0 +1,27 @@
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>
)
}

View File

@@ -0,0 +1,176 @@
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>
)
}

View File

@@ -0,0 +1,220 @@
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>
)
}

View File

@@ -0,0 +1,32 @@
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>
)
}

View File

@@ -0,0 +1,29 @@
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);
}
`

View File

@@ -5,9 +5,10 @@ import {
Center,
useColorMode
} from '@chakra-ui/react'
import { useThemeManager } from '../../hooks/useThemeManager';
export const PageLoader = () => {
const { colorMode } = useColorMode();
const { isLightVariant } = useThemeManager();
return (
<Container maxW="container.xl">
@@ -15,8 +16,8 @@ export const PageLoader = () => {
<Spinner
thickness="4px"
speed="0.65s"
emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'}
color={colorMode === 'light' ? 'blue.500' : 'blue.300'}
emptyColor={isLightVariant ? 'gray.200' : 'gray.600'}
color={isLightVariant ? 'blue.500' : 'blue.300'}
size="xl"
/>
</Center>

View File

@@ -0,0 +1 @@
export { ThemeSelector } from './theme-selector';

View File

@@ -0,0 +1,133 @@
import React, { useState, useCallback, MouseEvent } from 'react';
import {
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
Box,
Tooltip,
} from '@chakra-ui/react';
import {
MoonIcon,
SunIcon,
} from '@chakra-ui/icons';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useAppSelector } from '../../__data__/store';
import { selectCurrentTheme, setTheme, cycleNextTheme } from '../../__data__/slices/theme';
import { ThemeType } from '../../types/theme';
import {
LIGHT_THEME,
DARK_THEME,
PINK_THEME,
BLUE_THEME,
GREEN_THEME,
PURPLE_THEME,
THEMES
} from '../../utils/themes';
// Иконки для различных тем
const ThemeIcon: React.FC<{ theme: ThemeType }> = ({ theme }) => {
switch (theme) {
case LIGHT_THEME:
return <SunIcon />;
case DARK_THEME:
return <MoonIcon />;
case PINK_THEME:
return <Box>💕</Box>;
case BLUE_THEME:
return <Box>🌊</Box>;
case GREEN_THEME:
return <Box>🌿</Box>;
case PURPLE_THEME:
return <Box></Box>;
default:
return <SunIcon />;
}
};
// Цвета фона пунктов меню для предпросмотра
const getMenuItemStyles = (theme: ThemeType) => {
switch (theme) {
case LIGHT_THEME:
return { bg: 'white', color: 'black' };
case DARK_THEME:
return { bg: 'gray.800', color: 'white' };
case PINK_THEME:
return { bg: 'pink.100', color: 'pink.800' };
case BLUE_THEME:
return { bg: 'blue.100', color: 'blue.800' };
case GREEN_THEME:
return { bg: 'green.100', color: 'green.800' };
case PURPLE_THEME:
return { bg: 'purple.100', color: 'purple.800' };
default:
return {};
}
};
interface ThemeSelectorProps {
variant?: 'icon' | 'full';
size?: string;
}
export const ThemeSelector: React.FC<ThemeSelectorProps> = ({
variant = 'icon',
size = 'md'
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentTheme = useAppSelector(selectCurrentTheme);
// Состояние для контроля открытия меню
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Обработчик клика по иконке
const handleIconClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
// Если нажат Ctrl, отображаем меню
if (e.ctrlKey) {
setIsMenuOpen(true);
} else {
// Иначе просто циклически меняем тему
dispatch(cycleNextTheme());
}
}, [dispatch]);
// Обработчик выбора темы из меню
const handleThemeChange = (theme: ThemeType) => {
dispatch(setTheme(theme));
setIsMenuOpen(false);
};
// Рендерим одиночную иконку с меню при Ctrl+клик
return (
<Menu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)}>
<Tooltip
label={t('journal.pl.theme.select')}
aria-label={t('journal.pl.theme.select')}
>
<MenuButton
as={IconButton}
aria-label={t('journal.pl.theme.select')}
icon={<ThemeIcon theme={currentTheme} />}
variant="ghost"
size={size}
onClick={handleIconClick}
/>
</Tooltip>
<MenuList zIndex={1000}>
{THEMES.map((theme) => (
<MenuItem
key={theme}
onClick={() => handleThemeChange(theme)}
icon={<ThemeIcon theme={theme} />}
{...getMenuItemStyles(theme)}
>
{t(`journal.pl.theme.${theme}`)}
</MenuItem>
))}
</MenuList>
</Menu>
);
};

View File

@@ -109,35 +109,7 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number; pos
props.warn
? css`
opacity: 0.7;
filter: grayscale(0.8);
filter: grayscale(0.3);
`
: ''}
`
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);
}
`

View File

@@ -1,14 +1,15 @@
import React from 'react'
import React, { useMemo } from 'react'
import { sha256 } from 'js-sha256'
import { useState, useEffect, useRef } from 'react'
import { Box, useColorMode, Text } from '@chakra-ui/react'
import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons'
import { motion, AnimatePresence } from 'framer-motion'
import { useTranslation } from 'react-i18next'
import { useThemeManager } from '../../hooks/useThemeManager'
import { Reaction, User } from '../../__data__/model'
import { AddMissedButton, Avatar, Wrapper, NameOverlay } from './style'
import { Avatar, Wrapper, NameOverlay } from './style'
// Map of reaction types to emojis
const REACTION_EMOJIS = {
@@ -19,7 +20,7 @@ const REACTION_EMOJIS = {
clap: '👏'
}
export function getGravatarURL(email, user) {
export function getGravatarURL(email) {
if (!email) return void 0
const address = String(email).trim().toLowerCase()
const hash = sha256(address)
@@ -30,7 +31,6 @@ export function getGravatarURL(email, user) {
export const UserCard = ({
student,
present,
onAddUser = undefined,
wrapperAS = 'div',
width,
recentlyPresent = false,
@@ -39,7 +39,6 @@ export const UserCard = ({
student: User
present: boolean
width?: string | number
onAddUser?: (user: User) => void
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>
recentlyPresent?: boolean
reaction?: Reaction
@@ -49,6 +48,9 @@ export const UserCard = ({
const [imageError, setImageError] = useState(false);
const [showReaction, setShowReaction] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isLightVariant } = useThemeManager();
const randomGravatarPath = useMemo(() => Math.random() * 1000, [])
// Обрабатываем изменение реакции
useEffect(() => {
@@ -74,6 +76,53 @@ export const UserCard = ({
};
}, [reaction]);
// Функция для определения цвета статуса онлайн
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'green.500';
case 'away':
return 'yellow.500';
default:
return 'gray.400';
}
};
// Функция для рендера индикатора посещаемости
const renderAttendanceIndicator = (attendance: number) => {
let color;
let text;
if (attendance >= 90) {
color = 'green.500';
text = '✓✓✓';
} else if (attendance >= 70) {
color = 'green.400';
text = '✓✓';
} else if (attendance >= 50) {
color = 'yellow.500';
text = '✓';
} else if (attendance >= 30) {
color = 'orange.500';
text = '⚠';
} else {
color = 'red.500';
text = '✗';
}
return (
<Text
fontSize="xs"
color={color}
position="absolute"
bottom="8px"
right="8px"
>
{text}
</Text>
);
};
return (
<Wrapper
warn={!present}
@@ -83,7 +132,7 @@ export const UserCard = ({
position="relative"
>
<Avatar
src={imageError ? getGravatarURL(student.email, null) : (student.picture || getGravatarURL(student.email, null))}
src={imageError ? getGravatarURL(student.email || randomGravatarPath) : (student.picture || getGravatarURL(student.email))}
onError={() => {
if (!imageError && student.picture) {
setImageError(true);
@@ -98,11 +147,6 @@ export const UserCard = ({
</Box>
)}
</NameOverlay>
{onAddUser && !present && (
<AddMissedButton onClick={() => onAddUser(student)} aria-label={t('journal.pl.common.add')}>
<AddIcon boxSize={3} />
</AddMissedButton>
)}
{/* Анимация реакции */}
<AnimatePresence>
@@ -142,6 +186,19 @@ export const UserCard = ({
</motion.div>
)}
</AnimatePresence>
{/* Дополнительные декоративные элементы в зависимости от темы */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="5px"
background={isLightVariant ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)'}
borderTopLeftRadius="md"
borderTopRightRadius="md"
zIndex={1}
/>
</Wrapper>
)
}

View File

@@ -5,13 +5,14 @@ import {
Spinner,
useColorMode
} from '@chakra-ui/react'
import { useThemeManager } from '../../hooks/useThemeManager'
interface XlSpinnerProps {
size?: string;
}
export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => {
const { colorMode } = useColorMode();
const { isLightVariant } = useThemeManager();
return (
<Container maxW="container.xl">
@@ -19,8 +20,8 @@ export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => {
<Spinner
thickness="4px"
speed="0.65s"
emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'}
color={colorMode === 'light' ? 'blue.500' : 'blue.300'}
emptyColor={isLightVariant ? 'gray.200' : 'gray.600'}
color={isLightVariant ? 'blue.500' : 'blue.300'}
size={size}
/>
</Center>

View File

@@ -1,9 +1,9 @@
import React, { useEffect, Suspense, useRef, useState } from 'react'
import { Routes, Route, useNavigate } from 'react-router-dom'
import { Provider } from 'react-redux'
import { getNavigationValue } from '@brojs/cli'
import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { useThemeManager } from './hooks/useThemeManager'
import {
CourseListPage,
@@ -68,15 +68,12 @@ const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContain
return <AppHeader serviceMenuContainerRef={serviceMenuContainerRef} breadcrumbs={breadcrumbs} />;
};
interface DashboardProps {
store: any; // Используем any, поскольку точный тип store не указан
}
export const Dashboard = ({ store }: DashboardProps) => {
export const Dashboard = () => {
const serviceMenuContainerRef = useRef<HTMLDivElement>(null);
const serviceMenuInstanceRef = useRef<any>(null);
const [serviceMenu, setServiceMenu] = useState(false);
const { colorMode } = useColorMode();
const { isLightVariant, isDarkVariant } = useThemeManager();
const { t } = useTranslation();
useEffect(() => {
@@ -84,7 +81,7 @@ export const Dashboard = ({ store }: DashboardProps) => {
setServiceMenu(true)
}).catch(console.error)
}, [])
useEffect(() => {
// Проверяем, что библиотека загружена и есть контейнер для меню
if (window.createServiceMenu && serviceMenuContainerRef.current && serviceMenu) {
@@ -94,10 +91,10 @@ export const Dashboard = ({ store }: DashboardProps) => {
apiUrl: 'https://admin.bro-js.ru',
targetElement: serviceMenuContainerRef.current,
styles: {
dotColor: colorMode === 'light' ? '#333' : '#ccc',
hoverColor: colorMode === 'light' ? '#eee' : '#444',
backgroundColor: colorMode === 'light' ? '#fff' : '#2D3748',
textColor: colorMode === 'light' ? '#333' : '#fff',
dotColor: isLightVariant ? '#333' : '#ccc',
hoverColor: isLightVariant ? '#eee' : '#444',
backgroundColor: isLightVariant ? '#fff' : '#2D3748',
textColor: isLightVariant ? '#333' : '#fff',
},
translations: {
menuTitle: t('journal.pl.serviceMenu.title'),
@@ -105,7 +102,7 @@ export const Dashboard = ({ store }: DashboardProps) => {
}
});
}
// Очистка при размонтировании
return () => {
if (serviceMenuInstanceRef.current) {
@@ -113,10 +110,9 @@ export const Dashboard = ({ store }: DashboardProps) => {
serviceMenuInstanceRef.current = null;
}
};
}, [keycloak.token, serviceMenu, colorMode, t]);
}, [keycloak.token, serviceMenu, isLightVariant, isDarkVariant, t]);
return (
<Provider store={store}>
<BreadcrumbsProvider>
<HeaderWithBreadcrumbs serviceMenuContainerRef={serviceMenuContainerRef} />
<Routes>
@@ -162,6 +158,5 @@ export const Dashboard = ({ store }: DashboardProps) => {
/>
</Routes>
</BreadcrumbsProvider>
</Provider>
)
}

View File

@@ -7,6 +7,8 @@ html {
min-width: 320px;
overflow: hidden;
}
/* Светлая тема (light) - по умолчанию */
body {
color: #000;
/* background: radial-gradient(circle at top right, rgb(154 227 33), rgb(33 160 56)); */
@@ -31,7 +33,7 @@ body {
font-weight: 600;
}
/* Стили для темной темы */
/* Темная тема (dark) */
html[data-theme="dark"] body {
color: #fff;
background: radial-gradient(
@@ -51,6 +53,86 @@ html[data-theme="dark"] body {
);
}
/* Розовая тема (pink) */
html[data-theme="pink"] body {
color: #702459;
background: radial-gradient(
farthest-side at bottom left,
rgba(251, 182, 206, 0.8),
rgba(255, 255, 255, 0) 65%
),
radial-gradient(
farthest-corner at bottom center,
rgba(254, 215, 226, 0.7),
rgba(255, 255, 255, 0) 40%
),
radial-gradient(
farthest-side at bottom right,
rgba(246, 135, 179, 0.6),
rgb(255, 245, 247) 65%
);
}
/* Синяя тема (blue) */
html[data-theme="blue"] body {
color: #2c5282;
background: radial-gradient(
farthest-side at bottom left,
rgba(144, 205, 244, 0.8),
rgba(255, 255, 255, 0) 65%
),
radial-gradient(
farthest-corner at bottom center,
rgba(190, 227, 248, 0.7),
rgba(255, 255, 255, 0) 40%
),
radial-gradient(
farthest-side at bottom right,
rgba(66, 153, 225, 0.6),
rgb(235, 248, 255) 65%
);
}
/* Зеленая тема (green) */
html[data-theme="green"] body {
color: #276749;
background: radial-gradient(
farthest-side at bottom left,
rgba(154, 230, 180, 0.8),
rgba(255, 255, 255, 0) 65%
),
radial-gradient(
farthest-corner at bottom center,
rgba(198, 246, 213, 0.7),
rgba(255, 255, 255, 0) 40%
),
radial-gradient(
farthest-side at bottom right,
rgba(72, 187, 120, 0.6),
rgb(240, 255, 244) 65%
);
}
/* Фиолетовая тема (purple) */
html[data-theme="purple"] body {
color: #fff;
background: radial-gradient(
farthest-side at bottom left,
rgba(128, 90, 213, 0.8),
rgba(0, 0, 0, 0) 65%
),
radial-gradient(
farthest-corner at bottom center,
rgba(159, 122, 234, 0.7),
rgba(0, 0, 0, 0) 40%
),
radial-gradient(
farthest-side at bottom right,
rgba(107, 70, 193, 0.6),
rgb(44, 19, 56) 65%
);
}
#app {
height: 100%;
overflow-y: auto;

View File

@@ -0,0 +1,42 @@
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
}
}

56
src/hooks/useQRCode.ts Normal file
View File

@@ -0,0 +1,56 @@
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])
}

View File

@@ -0,0 +1,81 @@
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
}
}

View File

@@ -0,0 +1,40 @@
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
}
}

View File

@@ -0,0 +1,132 @@
import { useColorMode } from '@chakra-ui/react';
import { useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { ThemeType } from '../types/theme';
import {
LIGHT_THEME,
DARK_THEME,
PINK_THEME,
BLUE_THEME,
GREEN_THEME,
PURPLE_THEME,
THEMES
} from '../utils/themes';
import { useAppSelector } from '../__data__/store';
import {
setTheme,
cycleNextTheme as cycleTheme,
selectCurrentTheme,
selectIsLightVariant,
selectIsDarkVariant
} from '../__data__/slices/theme';
// Маппинг тем к базовым режимам Chakra UI
const themeToColorMode: Record<ThemeType, 'light' | 'dark'> = {
[LIGHT_THEME]: 'light',
[DARK_THEME]: 'dark',
[PINK_THEME]: 'light',
[BLUE_THEME]: 'light',
[GREEN_THEME]: 'light',
[PURPLE_THEME]: 'dark'
};
export const useThemeManager = () => {
// Получаем базовый функционал переключения темы из Chakra UI
const { colorMode, setColorMode } = useColorMode();
// Используем Redux для управления темой
const dispatch = useDispatch();
const currentTheme = useAppSelector(selectCurrentTheme);
const isLightVariant = useAppSelector(selectIsLightVariant);
const isDarkVariant = useAppSelector(selectIsDarkVariant);
// Ref для хранения observer
const observerRef = useRef<MutationObserver | null>(null);
// Функция для применения классов и атрибутов темы
const applyThemeToDOM = () => {
if (typeof document === 'undefined') return;
// Удаляем все классы тем
document.documentElement.classList.remove(...THEMES);
// Добавляем класс текущей темы
document.documentElement.classList.add(currentTheme);
// Также устанавливаем data-theme атрибут для использования в CSS
document.documentElement.setAttribute('data-theme', currentTheme);
// Устанавливаем специальный флаг, что тема установлена нами
document.documentElement.setAttribute('data-custom-theme-source', 'redux');
};
// Эффект для применения дополнительных классов к документу в зависимости от выбранной темы
// и создания MutationObserver для отслеживания изменений
useEffect(() => {
if (typeof document === 'undefined' || typeof window === 'undefined') return;
// Применяем тему к DOM
applyThemeToDOM();
// Синхронизируем с Chakra UI для светлой/темной темы
const requiredColorMode = themeToColorMode[currentTheme];
if (colorMode !== requiredColorMode) {
setColorMode(requiredColorMode);
}
// Создаем MutationObserver для отслеживания изменений атрибута data-theme
if (!observerRef.current) {
observerRef.current = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'data-theme' &&
document.documentElement.getAttribute('data-theme') !== currentTheme
) {
// Если атрибут был изменен не нами, восстанавливаем его
applyThemeToDOM();
}
});
});
// Начинаем наблюдение за изменениями атрибута data-theme
observerRef.current.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
}
// Для дополнительной защиты устанавливаем интервал для проверки и восстановления атрибута
const intervalId = setInterval(() => {
if (document.documentElement.getAttribute('data-theme') !== currentTheme) {
applyThemeToDOM();
}
}, 500);
// Отключаем observer и интервал при размонтировании компонента
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
clearInterval(intervalId);
};
}, [currentTheme, setColorMode, colorMode]);
// Функция для изменения темы
const changeTheme = (theme: ThemeType) => {
dispatch(setTheme(theme));
};
// Функция для последовательного циклического переключения тем
const cycleNextTheme = () => {
dispatch(cycleTheme());
};
return {
currentTheme,
changeTheme,
cycleNextTheme,
isLightVariant,
isDarkVariant,
};
};

View File

@@ -1,9 +1,7 @@
import React, { useEffect, useRef, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import QRCode from 'qrcode'
import React, { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { sha256 } from 'js-sha256'
import { getConfigValue, getNavigationValue } from '@brojs/cli'
import { motion, AnimatePresence } from 'framer-motion'
import { getNavigationValue } from '@brojs/cli'
import {
Box,
Container,
@@ -11,23 +9,24 @@ import {
Heading,
Stack,
useColorMode,
Flex,
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
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 {
QRCanvas,
StudentList,
} from './style'
import { useAppSelector } from '../__data__/store'
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) {
if (!email) return void 0
const address = String(email).trim().toLowerCase()
@@ -39,7 +38,6 @@ export function getGravatarURL(email, user) {
const LessonDetail = () => {
const { lessonId, courseId } = useParams()
const canvRef = useRef(null)
const user = useAppSelector((s) => s.user)
const { t } = useTranslation()
const { colorMode } = useColorMode()
@@ -64,191 +62,25 @@ const LessonDetail = () => {
}
])
// Создаем 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 {
isFetching,
data: accessCode,
accessCode,
isSuccess,
refetch,
} = api.useCreateAccessCodeQuery(
{ lessonId },
{
skip: !isTeacher(user),
pollingInterval:
Number(getConfigValue('journal.polling-interval')) || 3000,
skipPollingIfUnfocused: true,
},
)
handleManualAdd
} = useAccessCode(lessonId, user)
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(
() => `${location.origin}/journal/u/${lessonId}/${accessCode?.body?._id}`,
[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 (
<>
<Container maxW="2280px">
@@ -257,7 +89,6 @@ const LessonDetail = () => {
{t('journal.pl.lesson.topicTitle')}
</Heading>
<Box as="span">{accessCode?.body?.lesson?.name}</Box>
</VStack>
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
<Box
@@ -270,56 +101,19 @@ const LessonDetail = () => {
position="sticky"
top="20px"
zIndex="2"
><Box pb={3}>
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
{t('journal.pl.common.marked')} -
{AllStudents.isSuccess && (
<Box
as="span"
px={2}
py={1}
ml={2}
borderRadius="md"
fontWeight="bold"
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>
>
<AttendanceStats
lessonDate={accessCode?.body?.lesson?.date}
studentsPresent={accessCode?.body?.lesson?.students?.length || 0}
totalStudents={AllStudents?.data?.body?.length || 0}
isLoading={!AllStudents.isSuccess}
isPulsing={isPulsing}
/>
<QRCodeDisplay
url={userUrl}
isFetching={isFetching}
isSuccess={isSuccess}
/>
</Box>
<Box
flex={1}
@@ -328,174 +122,13 @@ const LessonDetail = () => {
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
>
<StudentList>
{isTeacher(user) && (
<AnimatePresence initial={false}>
{studentsArr.map((student) => (
<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}
onAddUser={(user: User) => manualAdd({ lessonId, user })}
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"
}}
>
<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>
{isTeacher(user) && (
<StudentList
students={studentsArr}
onAddUser={handleManualAdd}
studentReactions={studentReactions}
/>
)}
</Box>
</Stack>
</Container>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState, useRef, useCallback } from 'react'
import { getFeatures } from '@brojs/cli'
import { useForm, Controller } from 'react-hook-form'
import {
Box,
@@ -29,7 +30,11 @@ import {
Wrap,
WrapItem,
IconButton,
Center
Center,
InputGroup,
InputRightElement,
List,
ListItem
} from '@chakra-ui/react'
import { AddIcon, CheckIcon, WarningIcon, RepeatIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
@@ -40,6 +45,10 @@ import { formatDate } from '../../../utils/dayjs-config'
import { dateToCalendarFormat } from '../../../utils/time'
import { Lesson } from '../../../__data__/model'
import { ErrorSpan } from '../style'
import { api } from '../../../__data__/api/api'
import { useParams } from 'react-router-dom'
const courceNameSuggestion = getFeatures('journal')['courceNameSuggestion']
interface NewLessonForm {
name: string
@@ -78,6 +87,7 @@ export const LessonForm = ({
onRetryAiGeneration = () => {},
existingLessons
}: LessonFormProps) => {
const { courseId } = useParams()
const { t } = useTranslation()
const isAiSuggested = lesson && !lesson._id && !lesson.id
const aiHighlightColor = useColorModeValue('blue.100', 'blue.800')
@@ -85,6 +95,72 @@ export const LessonForm = ({
const suggestionHoverBgColor = useColorModeValue('blue.100', 'blue.800')
const borderColor = useColorModeValue('blue.200', 'blue.700')
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 now = new Date();
@@ -149,27 +225,6 @@ export const LessonForm = ({
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 = {
[`${t('journal.pl.days.morning')} (8-12)`]: generateTimeSlots().filter(slot => {
const hour = parseInt(slot.split(':')[0]);
@@ -490,12 +545,30 @@ export const LessonForm = ({
render={({ field }) => (
<FormControl isRequired isInvalid={Boolean(errors.name)}>
<FormLabel>{t('journal.pl.lesson.form.title')}</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.lesson.form.namePlaceholder')}
size="md"
/>
<Box position="relative">
<InputGroup>
<Input
{...field}
ref={inputRef}
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 && (
<FormErrorMessage>{errors.name.message}</FormErrorMessage>
)}
@@ -610,6 +683,39 @@ export const LessonForm = ({
</>
)}
</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>
)
}

1
src/types/theme.ts Normal file
View File

@@ -0,0 +1 @@
export type ThemeType = 'light' | 'dark' | 'pink' | 'blue' | 'green' | 'purple';

175
src/user-card/user-card.tsx Normal file
View File

@@ -0,0 +1,175 @@
import React from 'react';
import { Box, useColorMode, Text } from '@chakra-ui/react';
import { useThemeManager } from '../../hooks/useThemeManager';
interface UserCardProps {
user: {
id: string;
name: string;
photo?: string;
role?: string;
status?: 'online' | 'offline' | 'away';
attendance?: number;
};
onClick?: () => void;
showStatus?: boolean;
showAttendance?: boolean;
}
export const UserCard: React.FC<UserCardProps> = ({
user,
onClick,
showStatus = false,
showAttendance = false
}) => {
const { isLightVariant } = useThemeManager();
// Функция для определения цвета статуса онлайн
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'green.500';
case 'away':
return 'yellow.500';
default:
return 'gray.400';
}
};
// Функция для рендера индикатора посещаемости
const renderAttendanceIndicator = (attendance: number) => {
let color;
let text;
if (attendance >= 90) {
color = 'green.500';
text = '✓✓✓';
} else if (attendance >= 70) {
color = 'green.400';
text = '✓✓';
} else if (attendance >= 50) {
color = 'yellow.500';
text = '✓';
} else if (attendance >= 30) {
color = 'orange.500';
text = '⚠';
} else {
color = 'red.500';
text = '✗';
}
return (
<Text
fontSize="xs"
color={color}
position="absolute"
bottom="8px"
right="8px"
>
{text}
</Text>
);
};
return (
<Box
position="relative"
borderRadius="md"
overflow="hidden"
boxShadow="sm"
transition="transform 0.2s, box-shadow 0.2s"
_hover={onClick ? {
transform: 'translateY(-2px)',
boxShadow: 'md',
cursor: 'pointer'
} : {}}
onClick={onClick}
bg={isLightVariant ? 'white' : 'gray.700'}
p={3}
>
<Box
position="relative"
borderRadius="full"
width="60px"
height="60px"
overflow="hidden"
margin="0 auto 8px"
>
{user.photo ? (
<Box
as="img"
src={user.photo}
alt={user.name}
width="100%"
height="100%"
objectFit="cover"
/>
) : (
<Box
width="100%"
height="100%"
bg="gray.300"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="xl"
fontWeight="bold"
color="gray.600"
>
{user.name.charAt(0).toUpperCase()}
</Box>
)}
{showStatus && user.status && (
<Box
position="absolute"
bottom="2px"
right="2px"
width="12px"
height="12px"
borderRadius="full"
bg={getStatusColor(user.status)}
border="2px solid"
borderColor={isLightVariant ? 'white' : 'gray.700'}
/>
)}
</Box>
<Text
textAlign="center"
fontWeight="medium"
fontSize="sm"
noOfLines={1}
title={user.name}
>
{user.name}
</Text>
{user.role && (
<Text
textAlign="center"
fontSize="xs"
color={isLightVariant ? 'gray.500' : 'gray.300'}
noOfLines={1}
>
{user.role}
</Text>
)}
{showAttendance && typeof user.attendance === 'number' && renderAttendanceIndicator(user.attendance)}
{/* Дополнительные декоративные элементы в зависимости от темы */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="5px"
background={isLightVariant ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)'}
borderTopLeftRadius="md"
borderTopRightRadius="md"
zIndex={1}
/>
</Box>
);
};

View File

@@ -0,0 +1,10 @@
// Функция для определения цвета на основе посещаемости
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' } }
}

105
src/utils/theme.ts Normal file
View File

@@ -0,0 +1,105 @@
import { extendTheme, ThemeConfig } from '@chakra-ui/react';
import {
LIGHT_THEME,
DARK_THEME,
PINK_THEME,
BLUE_THEME,
GREEN_THEME,
PURPLE_THEME
} from './themes';
// Базовая конфигурация темы Chakra UI
const config: ThemeConfig = {
initialColorMode: LIGHT_THEME,
useSystemColorMode: false,
};
// Определяем специфичные цвета для различных тем
const colors = {
// Цвета для розовой темы
pinkTheme: {
50: '#fff5f7',
100: '#fed7e2',
200: '#fbb6ce',
300: '#f687b3',
400: '#ed64a6',
500: '#d53f8c',
600: '#b83280',
700: '#97266d',
800: '#702459',
900: '#521B41',
},
// Цвета для синей темы
blueTheme: {
50: '#ebf8ff',
100: '#bee3f8',
200: '#90cdf4',
300: '#63b3ed',
400: '#4299e1',
500: '#3182ce',
600: '#2b6cb0',
700: '#2c5282',
800: '#2a4365',
900: '#1A365D',
},
// Цвета для зеленой темы
greenTheme: {
50: '#f0fff4',
100: '#c6f6d5',
200: '#9ae6b4',
300: '#68d391',
400: '#48bb78',
500: '#38a169',
600: '#2f855a',
700: '#276749',
800: '#22543d',
900: '#1C4532',
},
// Цвета для фиолетовой темы
purpleTheme: {
50: '#faf5ff',
100: '#e9d8fd',
200: '#d6bcfa',
300: '#b794f4',
400: '#9f7aea',
500: '#805ad5',
600: '#6b46c1',
700: '#553c9a',
800: '#44337a',
900: '#322659',
},
};
// Создаем и экспортируем расширенную тему
export const chakraTheme = extendTheme({
config,
colors,
styles: {
global: (props: { colorMode: string }) => ({
// Базовые стили для темного и светлого режимов
body: {
bg: props.colorMode === 'dark' ? 'gray.800' : 'white',
color: props.colorMode === 'dark' ? 'white' : 'gray.800',
},
}),
},
components: {
// Настраиваем компоненты для поддержки дополнительных тем
Button: {
baseStyle: (props: { colorMode: string }) => ({
_focus: {
boxShadow:
props.colorMode === PINK_THEME
? '0 0 0 3px rgba(237, 100, 166, 0.6)'
: props.colorMode === BLUE_THEME
? '0 0 0 3px rgba(66, 153, 225, 0.6)'
: props.colorMode === GREEN_THEME
? '0 0 0 3px rgba(72, 187, 120, 0.6)'
: props.colorMode === PURPLE_THEME
? '0 0 0 3px rgba(159, 122, 234, 0.6)'
: undefined,
},
}),
},
},
});

45
src/utils/themes.ts Normal file
View File

@@ -0,0 +1,45 @@
import { ThemeType } from '../types/theme';
// Константы для названий тем
export const LIGHT_THEME = 'light';
export const DARK_THEME = 'dark';
export const PINK_THEME = 'pink';
export const BLUE_THEME = 'blue';
export const GREEN_THEME = 'green';
export const PURPLE_THEME = 'purple';
// Массив всех доступных тем для переключения
export const THEMES: ThemeType[] = [
LIGHT_THEME,
DARK_THEME,
PINK_THEME,
BLUE_THEME,
GREEN_THEME,
PURPLE_THEME,
];
// Функция для получения следующей темы в списке
export const getNextTheme = (currentTheme: ThemeType): ThemeType => {
const currentIndex = THEMES.indexOf(currentTheme);
return THEMES[(currentIndex + 1) % THEMES.length];
};
// Функция для получения иконки темы в зависимости от её типа
export const getThemeIcon = (theme: ThemeType): string => {
switch (theme) {
case LIGHT_THEME:
return 'sun';
case DARK_THEME:
return 'moon';
case PINK_THEME:
return 'heart';
case BLUE_THEME:
return 'water';
case GREEN_THEME:
return 'leaf';
case PURPLE_THEME:
return 'stars';
default:
return 'sun';
}
};

View File

@@ -131,6 +131,23 @@ router.get('/lesson/:courseId/ai/generate-lessons', timer(3000), (req, res) => {
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) => {
const baseData = readJsonFile('../mocks/lessons/create/success.json');