Files
journal.pl/src/pages/user-page.tsx
T

316 lines
10 KiB
TypeScript

import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'framer-motion'
import { api } from '../__data__/api/api'
import { formatDate } from '../utils/dayjs-config'
import {
Alert,
AlertIcon,
Box,
Center,
Container,
Spinner,
Text,
Heading,
Badge,
Flex,
useColorMode,
IconButton,
Tooltip,
HStack,
} from '@chakra-ui/react'
import { UserCard } from '../components/user-card'
import { StudentListView } from './style'
import { useSetBreadcrumbs } from '../components'
// Reaction emojis with their string values
const REACTIONS = [
{ emoji: '👍', value: 'thumbs_up' },
{ emoji: '❤️', value: 'heart' },
{ emoji: '😂', value: 'laugh' },
{ emoji: '😮', value: 'wow' },
{ emoji: '👏', value: 'clap' },
]
const UserPage = () => {
const { lessonId, accessId } = useParams()
const { t } = useTranslation()
const { colorMode } = useColorMode()
const acc = api.useGetAccessQuery({ accessCode: accessId })
const [animatedStudents, setAnimatedStudents] = useState([])
const [sendReaction] = api.useSendReactionMutation()
const [activeReaction, setActiveReaction] = useState(null)
const ls = api.useLessonByIdQuery(lessonId, {
pollingInterval: 1000,
skipPollingIfUnfocused: true,
})
// Устанавливаем хлебные крошки
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: t('journal.pl.breadcrumbs.user'),
isCurrentPage: true
}
])
// Эффект для поэтапного появления карточек студентов
useEffect(() => {
if (ls.data?.body?.students?.length) {
// Обновляем существующих студентов с сохранением их анимации
setAnimatedStudents(prevStudents => {
const newStudents = ls.data.body.students.map(student => {
// Находим существующего студента
const existingStudent = prevStudents.find(p => p.sub === student.sub);
// Сохраняем флаг isNew если студент уже существует
return {
...student,
isNew: existingStudent ? existingStudent.isNew : true
};
});
// Если количество студентов не изменилось, сохраняем текущий массив
if (prevStudents.length === newStudents.length &&
prevStudents.every(student => newStudents.find(n => n.sub === student.sub))) {
return prevStudents;
}
return newStudents;
});
}
}, [ls.data?.body?.students, ls.data?.body?.studentReactions])
// Эффект для сброса флага "новизны" студентов
useEffect(() => {
if (animatedStudents.length > 0) {
const timeoutId = setTimeout(() => {
setAnimatedStudents(students =>
students.map(student => ({...student, isNew: false}))
)
}, 2000)
return () => clearTimeout(timeoutId)
}
}, [animatedStudents])
// Обработчик отправки реакции
const handleReaction = (reaction) => {
if (lessonId) {
sendReaction({ lessonId, reaction })
setActiveReaction(reaction)
// Сбрасываем активную реакцию через 1 секунду
setTimeout(() => {
setActiveReaction(null)
}, 1000)
}
}
if (acc.isLoading) {
return (
<Container maxW="container.xl">
<Center h="300px">
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
</Center>
</Container>
)
}
return (
<Container maxW="container.lg" pt={4}>
{acc.isLoading && (
<Center py={4}>
<Spinner mr={2} />
<Text>{t('journal.pl.common.sending')}</Text>
</Center>
)}
{acc.isSuccess && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Alert status="success" mb={4} borderRadius="lg">
<AlertIcon />
{t('journal.pl.common.success')}
</Alert>
</motion.div>
)}
{acc.error && (
<Box mb="6" mt="2">
<Alert status="warning" borderRadius="lg">
<AlertIcon />
{(acc as any).error?.data?.body?.errorMessage ===
'Code is expired' ? (
t('journal.pl.access.expiredCode')
) : (
<pre>{JSON.stringify(acc.error, null, 4)}</pre>
)}
</Alert>
</Box>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<Box
mb={6}
p={5}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
>
<Heading fontSize="xl" fontWeight={600} mb={2}>
{t('journal.pl.lesson.topicTitle')}
<Box as="span" ml={2} color={colorMode === "light" ? "blue.500" : "blue.300"}>
{ls.data?.body?.name}
</Box>
</Heading>
<Flex align="center" justify="space-between" mt={3}>
<Text color={colorMode === "light" ? "gray.600" : "gray.300"}>
{formatDate(ls.data?.body?.date, t('journal.pl.lesson.dateFormat'))}
</Text>
<Badge colorScheme="green" fontSize="md" borderRadius="full" px={3} py={1}>
{t('journal.pl.common.people')}: {animatedStudents.length}
</Badge>
</Flex>
</Box>
</motion.div>
{/* Реакции на занятие */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<Box
mb={6}
p={5}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
>
<Text mb={3} fontWeight="medium">{t('journal.pl.lesson.reactions')}</Text>
<HStack spacing={3} justify="center">
{REACTIONS.map((reaction) => (
<Tooltip key={reaction.value} label={t(`journal.pl.reactions.${reaction.value}`)} placement="top">
<IconButton
aria-label={t(`journal.pl.reactions.${reaction.value}`)}
icon={<Text fontSize="24px">{reaction.emoji}</Text>}
size="lg"
variant={activeReaction === reaction.value ? "solid" : "outline"}
colorScheme={activeReaction === reaction.value ? "blue" : "gray"}
onClick={() => handleReaction(reaction.value)}
transition="all 0.2s"
_hover={{ transform: "scale(1.1)" }}
sx={{
animation: activeReaction === reaction.value
? "pulse 0.5s ease-in-out" : "none",
"@keyframes pulse": {
"0%": { transform: "scale(1)" },
"50%": { transform: "scale(1.2)" },
"100%": { transform: "scale(1)" }
}
}}
/>
</Tooltip>
))}
</HStack>
</Box>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
{animatedStudents.length > 0 ? (
<StudentListView>
<AnimatePresence initial={true}>
{animatedStudents.map((student) => (
<motion.li
key={student.sub}
layout
initial={{ opacity: 0, scale: 0.6, y: 20 }}
animate={{
opacity: 1,
scale: 1,
y: 0,
boxShadow: student.isNew
? ['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)'
}}
exit={{ opacity: 0, scale: 0.6, y: 20 }}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
delay: 0.03 * animatedStudents.indexOf(student), // Уменьшенная задержка для более плавного появления
boxShadow: {
repeat: student.isNew ? 3 : 0,
duration: 1.5
}
}}
>
<UserCard
width="100%"
wrapperAS="div"
student={student}
present={true}
recentlyPresent={student.isNew}
reaction={ls.data?.body?.studentReactions?.find(r => r.sub === student.sub)}
/>
</motion.li>
))}
</AnimatePresence>
</StudentListView>
) : (
ls.data && (
<Center py={10} px={5}>
<Box
textAlign="center"
p={6}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
width="100%"
maxWidth="500px"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Heading size="md" mb={4}>{t('journal.pl.lesson.noStudents')}</Heading>
<Text>{t('journal.pl.lesson.waitForStudents')}</Text>
</motion.div>
</Box>
</Center>
)
)}
</motion.div>
</Container>
)
}
export default UserPage