journal.pl/src/pages/lesson-details.tsx
2025-03-25 19:12:47 +03:00

515 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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