515 lines
20 KiB
TypeScript
515 lines
20 KiB
TypeScript
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
|