import React, { useEffect, useRef, useMemo, useState } from 'react' import { useParams, Link } from 'react-router-dom' import QRCode from 'qrcode' import { sha256 } from 'js-sha256' import { getConfigValue, getNavigationValue } from '@brojs/cli' import { motion, AnimatePresence } from 'framer-motion' import { Box, Container, VStack, 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' export function getGravatarURL(email, user) { if (!email) return void 0 const address = String(email).trim().toLowerCase() const hash = sha256(address) // Grab the actual image URL return `https://www.gravatar.com/avatar/${hash}?d=robohash` } const LessonDetail = () => { const { lessonId, courseId } = useParams() const canvRef = useRef(null) const user = useAppSelector((s) => s.user) const { t } = useTranslation() const { colorMode } = useColorMode() // Получаем данные о курсе и уроке const { data: courseData } = api.useGetCourseByIdQuery(courseId) const { data: lessonData } = api.useLessonByIdQuery(lessonId) // Устанавливаем хлебные крошки useSetBreadcrumbs([ { title: t('journal.pl.breadcrumbs.home'), path: '/' }, { title: courseData?.name || t('journal.pl.breadcrumbs.course'), path: `${getNavigationValue('journal.main')}/lessons-list/${courseId}` }, { title: lessonData?.body?.name || t('journal.pl.breadcrumbs.lesson'), 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 { isFetching, data: accessCode, isSuccess, refetch, } = api.useCreateAccessCodeQuery( { lessonId }, { skip: !isTeacher(user), pollingInterval: Number(getConfigValue('journal.polling-interval')) || 3000, skipPollingIfUnfocused: true, }, ) const AllStudents = api.useCourseAllStudentsQuery(courseId) const [manualAdd, manualAddRqst] = api.useManualAddStudentMutation() 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?.reactions) { const reactions = accessCode.body.lesson.reactions; // Группируем реакции по sub (идентификатору студента) const groupedReactions: Record<string, Reaction[]> = {}; reactions.forEach(reaction => { if (!groupedReactions[reaction.sub]) { groupedReactions[reaction.sub] = []; } // Добавляем только новые реакции const isNewReaction = !prevReactionsRef.current[reaction.sub]?.some( r => r._id === reaction._id ); if (isNewReaction) { groupedReactions[reaction.sub].push(reaction); } }); // Обновляем отображаемые реакции setStudentReactions(groupedReactions); // Обновляем предыдущие реакции prevReactionsRef.current = { ...groupedReactions }; // Сбрасываем отображаемые реакции через некоторое время const clearReactionsTimeout = setTimeout(() => { setStudentReactions({}); }, 5000); return () => clearTimeout(clearReactionsTimeout); } }, [accessCode?.body?.lesson?.reactions]); 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"> <VStack align="left"> <Heading as="h3" mt="4" mb="3"> {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 flexShrink={0} alignSelf="flex-start" p={4} borderRadius="xl" bg={colorMode === "light" ? "gray.50" : "gray.700"} boxShadow="md" ><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> </Box> <Box flex={1} p={4} borderRadius="xl" 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 })} reactions={studentReactions[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> </Box> </Stack> </Container> </> ) } export default LessonDetail