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()) // Добавляем состояние для отслеживания пульсации const [isPulsing, setIsPulsing] = useState(false) // Отслеживаем предыдущее количество студентов const prevStudentCountRef = useRef(0) // Отслеживаем предыдущие реакции для определения новых const prevReactionsRef = useRef>({}) // Храним актуальные реакции студентов const [studentReactions, setStudentReactions] = useState>({}) 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 = {}; 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 ( <> {t('journal.pl.lesson.topicTitle')} {accessCode?.body?.lesson?.name} {formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '} {t('journal.pl.common.marked')} - {AllStudents.isSuccess && ( {accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length} )} {!AllStudents.isSuccess && ( {accessCode?.body?.lesson?.students?.length} )}{' '} {t('journal.pl.common.people')} {isTeacher(user) && ( {studentsArr.map((student) => ( {/* Front side - visible when present */} manualAdd({ lessonId, user })} reactions={studentReactions[student.sub] || []} /> {/* Back side - visible when not present */} {/* Академическая шапочка */} {/* Лицо студента */} {/* Тело студента */} {student.name || student.preferred_username} {t('journal.pl.lesson.notMarked')} ))} )} ) } export default LessonDetail